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:
Michal Srb 2020-08-08 21:04:15 -07:00 committed by GitHub
parent 5f195694ee
commit 26f67d27ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 3879 additions and 830 deletions

View File

@ -11,6 +11,7 @@ import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -53,7 +54,12 @@ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, activePointIndex } = appState.editingLinearElement; const {
elementId,
activePointIndex,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return false; return false;
@ -62,7 +68,7 @@ export const actionDeleteSelected = register({
// case: no point selected → delete whole element // case: no point selected → delete whole element
activePointIndex == null || activePointIndex == null ||
activePointIndex === -1 || activePointIndex === -1 ||
// case: deleting last point // case: deleting last remaining point
element.points.length < 2 element.points.length < 2
) { ) {
const nextElements = elements.filter((el) => el.id !== element.id); 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"); LinearElementEditor.movePoint(element, activePointIndex, "delete");
return { return {
@ -86,6 +103,7 @@ export const actionDeleteSelected = register({
...appState, ...appState,
editingLinearElement: { editingLinearElement: {
...appState.editingLinearElement, ...appState.editingLinearElement,
...binding,
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0, activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
}, },
}, },
@ -97,6 +115,10 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: nextAppState, appState: nextAppState,
} = deleteSelectedElements(elements, appState); } = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
);
nextAppState = handleGroupEditingState(nextAppState, nextElements); nextAppState = handleGroupEditingState(nextAppState, nextElements);

View File

@ -11,6 +11,9 @@ import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", 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 { return {
appState: selectGroupsForSelectedElements( ...duplicateElements(elements, appState),
{
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as any),
},
getNonDeletedElements(finalElements),
),
elements: finalElements,
commitToHistory: true, 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),
),
};
};

View File

@ -9,15 +9,32 @@ import { register } from "./register";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math"; import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor"; 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({ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId } = appState.editingLinearElement; const {
elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (element) { if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
);
}
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@ -66,11 +83,12 @@ export const actionFinalize = register({
// If the multi point line closes the loop, // If the multi point line closes the loop,
// set the last point to first point. // set the last point to first point.
// This ensures that loop remains closed at different scales. // This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points);
if ( if (
multiPointElement.type === "line" || multiPointElement.type === "line" ||
multiPointElement.type === "draw" multiPointElement.type === "draw"
) { ) {
if (isPathALoop(multiPointElement.points)) { if (isLoop) {
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
mutateElement(multiPointElement, { 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) { if (!appState.elementLocked) {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
@ -101,6 +136,8 @@ export const actionFinalize = register({
draggingElement: null, draggingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && !appState.elementLocked multiPointElement && !appState.elementLocked
? { ? {

View File

@ -9,6 +9,7 @@ import { AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getElementMap } from "../element"; import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
@ -31,6 +32,9 @@ const writeData = (
const nextElements = data.elements; const nextElements = data.elements;
const nextElementMap = getElementMap(nextElements); const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
);
const elements = nextElements const elements = nextElements
.map((nextElement) => .map((nextElement) =>
newElementWith( newElementWith(
@ -39,14 +43,11 @@ const writeData = (
), ),
) )
.concat( .concat(
prevElements deletedElements.map((prevElement) =>
.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }), newElementWith(prevElement, { isDeleted: true }),
), ),
); );
fixBindingsAfterDeletion(elements, deletedElements);
return { return {
elements, elements,

View File

@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit<
resizingElement: null, resizingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null,
editingLinearElement: null, editingLinearElement: null,
elementType: "selection", elementType: "selection",
elementLocked: false, elementLocked: false,
@ -43,6 +44,7 @@ export const getDefaultAppState = (): Omit<
scrolledOutside: false, scrolledOutside: false,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
username: "", username: "",
isBindingEnabled: true,
isCollaborating: false, isCollaborating: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@ -55,6 +57,7 @@ export const getDefaultAppState = (): Omit<
collaborators: new Map(), collaborators: new Map(),
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showShortcutsDialog: false, showShortcutsDialog: false,
suggestedBindings: [],
zenModeEnabled: false, zenModeEnabled: false,
gridSize: null, gridSize: null,
editingGroupId: null, editingGroupId: null,
@ -96,6 +99,7 @@ const APP_STATE_STORAGE_CONF = (<
cursorY: { browser: true, export: false }, cursorY: { browser: true, export: false },
draggingElement: { browser: false, export: false }, draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false }, editingElement: { browser: false, export: false },
startBoundElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false }, editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false }, editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false }, elementLocked: { browser: true, export: false },
@ -104,6 +108,7 @@ const APP_STATE_STORAGE_CONF = (<
exportBackground: { browser: true, export: false }, exportBackground: { browser: true, export: false },
gridSize: { browser: true, export: true }, gridSize: { browser: true, export: true },
height: { browser: false, export: false }, height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isCollaborating: { browser: false, export: false }, isCollaborating: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false }, isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false }, isLoading: { browser: false, export: false },
@ -124,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
shouldAddWatermark: { browser: true, export: false }, shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false }, shouldCacheIgnoreZoom: { browser: true, export: false },
showShortcutsDialog: { browser: false, export: false }, showShortcutsDialog: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
username: { browser: true, export: false }, username: { browser: true, export: false },
viewBackgroundColor: { browser: true, export: true }, viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false }, width: { browser: false, export: false },

View File

@ -31,6 +31,7 @@ import {
dragSelectedElements, dragSelectedElements,
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
hitTest,
} from "../element"; } from "../element";
import { import {
getElementsWithinSelection, getElementsWithinSelection,
@ -60,6 +61,8 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
} from "../element/types"; } from "../element/types";
import { distance2d, isPathALoop, getGridPoint } from "../math"; import { distance2d, isPathALoop, getGridPoint } from "../math";
@ -136,7 +139,13 @@ import { generateCollaborationLink, getCollaborationLinkData } from "../data";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { invalidateShapeForElement } from "../renderer/renderElement"; import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom"; 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 { actionFinalize, actionDeleteSelected } from "../actions";
import { import {
restoreUsernameFromLocalStorage, restoreUsernameFromLocalStorage,
@ -154,6 +163,19 @@ import {
} from "../groups"; } from "../groups";
import { Library } from "../data/library"; import { Library } from "../data/library";
import Scene from "../scene/Scene"; 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). * @param func handler taking at most single parameter (event).
@ -407,6 +429,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
private onBlur = withBatchedUpdates(() => { private onBlur = withBatchedUpdates(() => {
isHoldingSpace = false; isHoldingSpace = false;
this.setState({ isBindingEnabled: true });
this.saveDebounced(); this.saveDebounced();
this.saveDebounced.flush(); this.saveDebounced.flush();
}); });
@ -690,7 +713,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.broadcastScene(SCENE.UPDATE, /* syncAll */ true); this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
}, SYNC_FULL_SCENE_INTERVAL_MS); }, SYNC_FULL_SCENE_INTERVAL_MS);
componentDidUpdate(prevProps: ExcalidrawProps) { componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
const { width: prevWidth, height: prevHeight } = prevProps; const { width: prevWidth, height: prevHeight } = prevProps;
const { width: currentWidth, height: currentHeight } = this.props; const { width: currentWidth, height: currentHeight } = this.props;
if (prevWidth !== currentWidth || prevHeight !== currentHeight) { if (prevWidth !== currentWidth || prevHeight !== currentHeight) {
@ -714,6 +737,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.actionManager.executeAction(actionFinalize); 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: { const cursorButton: {
[id: string]: string | undefined; [id: string]: string | undefined;
@ -950,16 +992,31 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const dy = y - elementsCenterY; const dy = y - elementsCenterY;
const groupIdMap = new Map(); const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
const newElements = clipboardElements.map((element) => { 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, x: element.x + dx - minX,
y: element.y + dy - minY, y: element.y + dy - minY,
},
);
oldIdToDuplicatedId.set(element.id, newElement.id);
return newElement;
}); });
}); const nextElements = [
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
...newElements, ...newElements,
]); ];
fixBindingsAfterDuplication(
nextElements,
clipboardElements,
oldIdToDuplicatedId,
);
this.scene.replaceAllElements(nextElements);
history.resumeRecording(); history.resumeRecording();
this.setState( this.setState(
selectGroupsForSelectedElements( selectGroupsForSelectedElements(
@ -1403,6 +1460,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) { if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
this.toggleGridMode(); this.toggleGridMode();
} }
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === "KeyC" && event.altKey && event.shiftKey) { if (event.code === "KeyC" && event.altKey && event.shiftKey) {
this.copyToClipboardAsPng(); this.copyToClipboardAsPng();
@ -1511,6 +1571,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
isHoldingSpace = false; isHoldingSpace = false;
} }
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
this.setState({ isBindingEnabled: true });
}
}); });
private selectShapeTool(elementType: AppState["elementType"]) { private selectShapeTool(elementType: AppState["elementType"]) {
@ -1520,6 +1583,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isToolIcon(document.activeElement)) { if (isToolIcon(document.activeElement)) {
document.activeElement.blur(); document.activeElement.blur();
} }
if (!isLinearElementType(elementType)) {
this.setState({ suggestedBindings: [] });
}
if (elementType !== "selection") { if (elementType !== "selection") {
this.setState({ this.setState({
elementType, elementType,
@ -1558,10 +1624,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
gesture.initialScale = null; gesture.initialScale = null;
}); });
private setElements = (elements: readonly ExcalidrawElement[]) => {
this.scene.replaceAllElements(elements);
};
private handleTextWysiwyg( private handleTextWysiwyg(
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
@ -1612,6 +1674,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
[element.id]: true, [element.id]: true,
}, },
})); }));
} else {
fixBindingsAfterDeletion(this.scene.getElements(), [element]);
} }
if (!isDeleted || isExistingElement) { if (!isDeleted || isExistingElement) {
history.resumeRecording(); history.resumeRecording();
@ -1643,13 +1707,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
x: number, x: number,
y: number, y: number,
): NonDeleted<ExcalidrawTextElement> | null { ): NonDeleted<ExcalidrawTextElement> | null {
const element = getElementAtPosition( const element = this.getElementAtPosition(x, y);
this.scene.getElements(),
this.state,
x,
y,
this.state.zoom,
);
if (element && isTextElement(element) && !element.isDeleted) { if (element && isTextElement(element) && !element.isDeleted) {
return element; return element;
@ -1657,6 +1715,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return null; 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 = ({ private startTextEditing = ({
sceneX, sceneX,
sceneY, sceneY,
@ -1786,14 +1853,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const selectedGroupIds = getSelectedGroupIds(this.state); const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) { if (selectedGroupIds.length > 0) {
const elements = this.scene.getElements(); const hitElement = this.getElementAtPosition(sceneX, sceneY);
const hitElement = getElementAtPosition(
elements,
this.state,
sceneX,
sceneY,
this.state.zoom,
);
const selectedGroupId = const selectedGroupId =
hitElement && hitElement &&
@ -1873,12 +1933,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
} }
const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
this.canvas, this.canvas,
window.devicePixelRatio, window.devicePixelRatio,
); );
const { x: scenePointerX, y: scenePointerY } = scenePointer;
if ( if (
this.state.editingLinearElement && this.state.editingLinearElement &&
@ -1894,6 +1955,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (editingLinearElement !== this.state.editingLinearElement) { if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({ 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) { if (this.state.multiElement) {
@ -1954,6 +2036,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
} }
} }
return; return;
} }
@ -2003,13 +2086,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return; return;
} }
} }
const hitElement = getElementAtPosition( const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY);
elements,
this.state,
scenePointerX,
scenePointerY,
this.state.zoom,
);
if (this.state.elementType === "text") { if (this.state.elementType === "text") {
document.documentElement.style.cursor = isTextElement(hitElement) document.documentElement.style.cursor = isTextElement(hitElement)
? CURSOR_TYPE.TEXT ? CURSOR_TYPE.TEXT
@ -2328,24 +2405,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return; return;
} }
if (pointerDownState.scrollbars.isOverHorizontal) { this.handlePointerMoveOverScrollbars(event, pointerDownState);
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;
}
}); });
const onPointerUp = withBatchedUpdates(() => { const onPointerUp = withBatchedUpdates(() => {
@ -2440,8 +2500,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.state, this.state,
(appState) => this.setState(appState), (appState) => this.setState(appState),
history, history,
pointerDownState.origin.x, pointerDownState.origin,
pointerDownState.origin.y,
); );
if (ret.hitElement) { if (ret.hitElement) {
pointerDownState.hit.element = 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 // hitElement may already be set above, so check first
pointerDownState.hit.element = pointerDownState.hit.element =
pointerDownState.hit.element ?? pointerDownState.hit.element ??
getElementAtPosition( this.getElementAtPosition(
elements,
this.state,
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
this.state.zoom,
); );
this.maybeClearSelectionWhenHittingElement( this.maybeClearSelectionWhenHittingElement(
@ -2544,7 +2600,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
elementType: "draw" | "line" | "arrow", elementType: ExcalidrawLinearElement["type"],
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): void => { ): void => {
if (this.state.multiElement) { if (this.state.multiElement) {
@ -2616,6 +2672,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
mutateElement(element, { mutateElement(element, {
points: [...element.points, [0, 0]], points: [...element.points, [0, 0]],
}); });
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene,
);
this.scene.replaceAllElements([ this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
element, element,
@ -2623,6 +2683,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({ this.setState({
draggingElement: element, draggingElement: element,
editingElement: element, editingElement: element,
startBoundElement: boundElement,
suggestedBindings: [],
}); });
} }
}; };
@ -2690,33 +2752,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return; return;
} }
if (pointerDownState.scrollbars.isOverHorizontal) { if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
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; return;
} }
if (pointerDownState.scrollbars.isOverVertical) { const pointerCoords = viewportCoordsToSceneCoords(
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(
event, event,
this.state, this.state,
this.canvas, this.canvas,
window.devicePixelRatio, 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 // for arrows/lines, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when // 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 ( if (
distance2d( distance2d(
x, pointerCoords.x,
y, pointerCoords.y,
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
) < DRAGGING_THRESHOLD ) < DRAGGING_THRESHOLD
@ -2753,8 +2803,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
isRotating: resizeHandle === "rotation", isRotating: resizeHandle === "rotation",
}); });
const [resizeX, resizeY] = getGridPoint( const [resizeX, resizeY] = getGridPoint(
x - pointerDownState.resize.offset.x, pointerCoords.x - pointerDownState.resize.offset.x,
y - pointerDownState.resize.offset.y, pointerCoords.y - pointerDownState.resize.offset.y,
this.state.gridSize, this.state.gridSize,
); );
if ( if (
@ -2775,6 +2825,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
pointerDownState.resize.originalElements, pointerDownState.resize.originalElements,
) )
) { ) {
this.maybeSuggestBindingForAll(selectedElements);
return; return;
} }
} }
@ -2783,13 +2834,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const didDrag = LinearElementEditor.handlePointDragging( const didDrag = LinearElementEditor.handlePointDragging(
this.state, this.state,
(appState) => this.setState(appState), (appState) => this.setState(appState),
x, pointerCoords.x,
y, pointerCoords.y,
(element, startOrEnd) => {
this.maybeSuggestBindingForLinearElementAtCursor(
element,
startOrEnd,
pointerCoords,
);
},
); );
if (didDrag) { if (didDrag) {
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = pointerCoords.y;
return; return;
} }
} }
@ -2805,11 +2863,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
); );
if (selectedElements.length > 0) { if (selectedElements.length > 0) {
const [dragX, dragY] = getGridPoint( const [dragX, dragY] = getGridPoint(
x - pointerDownState.drag.offset.x, pointerCoords.x - pointerDownState.drag.offset.x,
y - pointerDownState.drag.offset.y, pointerCoords.y - pointerDownState.drag.offset.y,
this.state.gridSize, 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 // We duplicate the selected element if alt is pressed on pointer move
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
@ -2822,6 +2881,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const nextElements = []; const nextElements = [];
const elementsToAppend = []; const elementsToAppend = [];
const groupIdMap = new Map(); const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
for (const element of this.scene.getElementsIncludingDeleted()) { for (const element of this.scene.getElementsIncludingDeleted()) {
if ( if (
this.state.selectedElementIds[element.id] || this.state.selectedElementIds[element.id] ||
@ -2846,14 +2906,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
nextElements.push(duplicatedElement); nextElements.push(duplicatedElement);
elementsToAppend.push(element); elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
} else { } else {
nextElements.push(element); nextElements.push(element);
} }
} }
this.scene.replaceAllElements([ const nextSceneElements = [...nextElements, ...elementsToAppend];
...nextElements, fixBindingsAfterDuplication(
...elementsToAppend, nextSceneElements,
]); elementsToAppend,
oldIdToDuplicatedId,
"duplicatesServeAsOld",
);
this.scene.replaceAllElements(nextSceneElements);
} }
return; return;
} }
@ -2872,8 +2937,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
let dx: number; let dx: number;
let dy: number; let dy: number;
if (draggingElement.type === "draw") { if (draggingElement.type === "draw") {
dx = x - draggingElement.x; dx = pointerCoords.x - draggingElement.x;
dy = y - draggingElement.y; dy = pointerCoords.y - draggingElement.y;
} else { } else {
dx = gridX - draggingElement.x; dx = gridX - draggingElement.x;
dy = gridY - draggingElement.y; 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") { } else if (draggingElement.type === "selection") {
dragNewElement( dragNewElement(
draggingElement, draggingElement,
this.state.elementType, this.state.elementType,
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
x, pointerCoords.x,
y, pointerCoords.y,
distance(pointerDownState.origin.x, x), distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, y), distance(pointerDownState.origin.y, pointerCoords.y),
getResizeWithSidesSameLengthKey(event), getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event), getResizeCenterPointKey(event),
); );
@ -2929,6 +3003,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
getResizeWithSidesSameLengthKey(event), getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event), getResizeCenterPointKey(event),
); );
this.maybeSuggestBindingForAll([draggingElement]);
} }
if (this.state.elementType === "selection") { 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( private onPointerUpFromPointerDownHandler(
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): (event: PointerEvent) => void { ): (event: PointerEvent) => void {
@ -2973,6 +3075,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
multiElement, multiElement,
elementType, elementType,
elementLocked, elementLocked,
isResizing,
} = this.state; } = this.state;
this.setState({ this.setState({
@ -2991,14 +3094,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
// if moving start/end point towards start/end point within threshold, // Handle end of dragging a point of a linear element, might close a loop
// close the loop // and sets binding element
if (this.state.editingLinearElement) { if (this.state.editingLinearElement) {
const editingLinearElement = LinearElementEditor.handlePointerUp( const editingLinearElement = LinearElementEditor.handlePointerUp(
childEvent,
this.state.editingLinearElement, this.state.editingLinearElement,
this.state,
); );
if (editingLinearElement !== this.state.editingLinearElement) { 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) { if (draggingElement!.points.length > 1) {
history.resumeRecording(); history.resumeRecording();
} }
if ( const pointerCoords = viewportCoordsToSceneCoords(
!pointerDownState.drag.hasOccurred &&
draggingElement &&
!multiElement
) {
const { x, y } = viewportCoordsToSceneCoords(
childEvent, childEvent,
this.state, this.state,
this.canvas, this.canvas,
window.devicePixelRatio, window.devicePixelRatio,
); );
if (
!pointerDownState.drag.hasOccurred &&
draggingElement &&
!multiElement
) {
mutateElement(draggingElement, { mutateElement(draggingElement, {
points: [ points: [
...draggingElement.points, ...draggingElement.points,
[x - draggingElement.x, y - draggingElement.y], [
pointerCoords.x - draggingElement.x,
pointerCoords.y - draggingElement.y,
],
], ],
}); });
this.setState({ this.setState({
@ -3043,6 +3154,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
editingElement: this.state.draggingElement, editingElement: this.state.draggingElement,
}); });
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } 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) { if (!elementLocked) {
resetCursor(); resetCursor();
this.setState((prevState) => ({ this.setState((prevState) => ({
@ -3086,6 +3209,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
draggingElement, draggingElement,
getNormalizedDimensions(draggingElement), getNormalizedDimensions(draggingElement),
); );
if (
isBindingEnabled(this.state) &&
isBindableElement(draggingElement)
) {
maybeBindBindableElement(draggingElement);
}
} }
if (resizingElement) { if (resizingElement) {
@ -3155,20 +3285,80 @@ class App extends React.Component<ExcalidrawProps, AppState> {
history.resumeRecording(); history.resumeRecording();
} }
if (pointerDownState.drag.hasOccurred || isResizing) {
(isBindingEnabled(this.state)
? bindOrUnbindSelectedElements
: unbindLinearElements)(
getSelectedElements(this.scene.getElements(), this.state),
);
}
if (!elementLocked) { if (!elementLocked) {
resetCursor(); resetCursor();
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
suggestedBindings: [],
elementType: "selection", elementType: "selection",
}); });
} else { } else {
this.setState({ this.setState({
draggingElement: null, 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( private maybeClearSelectionWhenHittingElement(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
hitElement: ExcalidrawElement | null, hitElement: ExcalidrawElement | null,
@ -3291,13 +3481,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
); );
const elements = this.scene.getElements(); const elements = this.scene.getElements();
const element = getElementAtPosition( const element = this.getElementAtPosition(x, y);
elements,
this.state,
x,
y,
this.state.zoom,
);
if (!element) { if (!element) {
ContextMenu.push({ ContextMenu.push({
options: [ options: [

View File

@ -590,7 +590,13 @@ const LayerUI = ({
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => { const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const { cursorX, cursorY, ...ret } = appState; const {
cursorX,
cursorY,
suggestedBindings,
startBoundElement: boundElement,
...ret
} = appState;
return ret; return ret;
}; };
const prevAppState = getNecessaryObj(prev.appState); const prevAppState = getNecessaryObj(prev.appState);

View File

@ -48,7 +48,8 @@ function migrateElementWithProperties<T extends ExcalidrawElement>(
width: element.width || 0, width: element.width || 0,
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
groupIds: element.groupIds || [], groupIds: element.groupIds ?? [],
boundElementIds: element.boundElementIds ?? [],
}; };
return { return {
@ -85,6 +86,8 @@ const migrateElement = (
case "line": case "line":
case "arrow": { case "arrow": {
return migrateElementWithProperties(element, { return migrateElementWithProperties(element, {
startBinding: element.startBinding,
endBinding: element.endBinding,
points: points:
// migrate old arrow model to new one // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
@ -98,7 +101,9 @@ const migrateElement = (
} }
// generic elements // generic elements
case "ellipse": case "ellipse":
return migrateElementWithProperties(element, {});
case "rectangle": case "rectangle":
return migrateElementWithProperties(element, {});
case "diamond": case "diamond":
return migrateElementWithProperties(element, {}); return migrateElementWithProperties(element, {});

674
src/element/binding.ts Normal file
View 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;
};

View File

@ -10,11 +10,14 @@ import {
import { isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points"; 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 // 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. // This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = ( export const getElementAbsoluteCoords = (
element: ExcalidrawElement, element: ExcalidrawElement,
): [number, number, number, number] => { ): Bounds => {
if (isLinearElement(element)) { if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(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) => { export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0 // Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it // 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 rightY = Math.floor(element.height / 2) + 1;
const bottomX = topX; const bottomX = topX;
const bottomY = element.height; const bottomY = element.height;
const leftX = topY; const leftX = 0;
const leftY = rightY; const leftY = rightY;
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];

View File

@ -1,23 +1,28 @@
import { import * as GA from "../ga";
distanceBetweenPointAndSegment, import * as GAPoint from "../gapoints";
isPathALoop, import * as GADirection from "../gadirections";
rotate, import * as GALine from "../galines";
isPointInPolygon, import * as GATransform from "../gatransforms";
} from "../math";
import { isPathALoop, isPointInPolygon, rotate } from "../math";
import { pointsOnBezierCurves } from "points-on-curve"; import { pointsOnBezierCurves } from "points-on-curve";
import { NonDeletedExcalidrawElement } from "./types";
import { import {
getDiamondPoints, NonDeletedExcalidrawElement,
getElementAbsoluteCoords, ExcalidrawBindableElement,
getCurvePathOps, ExcalidrawElement,
} from "./bounds"; ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
NonDeleted,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
import { Point } from "../types"; import { Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@ -40,30 +45,138 @@ export const hitTest = (
appState: AppState, appState: AppState,
x: number, x: number,
y: number, y: number,
zoom: number,
): boolean => { ): boolean => {
// For shapes that are composed of lines, we only enable point-selection when the distance // How many pixels off the shape boundary we still consider a hit
// of the click is less than x pixels of any of the lines that the shape is composed of const threshold = 10 / appState.zoom;
const lineThreshold = 10 / 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); export const bindingBorderTest = (
const cx = (x1 + x2) / 2; element: NonDeleted<ExcalidrawBindableElement>,
const cy = (y1 + y2) / 2; { x, y }: { x: number; y: number },
// reverse rotate the pointer ): boolean => {
[x, y] = rotate(x, y, cx, cy, -element.angle); 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") { export const maxBindingGap = (
// https://stackoverflow.com/a/46007540/232122 element: ExcalidrawElement,
const px = Math.abs(x - element.x - element.width / 2); elementWidth: number,
const py = Math.abs(y - element.y - element.height / 2); 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 tx = 0.707;
let ty = 0.707; let ty = 0.707;
const a = Math.abs(element.width) / 2; const a = hwidth;
const b = Math.abs(element.height) / 2; 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 xx = a * tx;
const yy = b * ty; const yy = b * ty;
@ -86,133 +199,421 @@ export const hitTest = (
ty /= t; ty /= t;
}); });
if (isElementDraggableFromInside(element, appState)) { const closestPoint = GA.point(a * tx, b * ty);
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
);
}
// (x1, y1) --A-- (x2, y1) const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
// |D |B return [pointRel, tangent];
// (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);
if (isElementDraggableFromInside(element, appState)) { const hitTestLinear = (args: HitTestArgs): boolean => {
// TODO: remove this when we normalize coordinates globally const { element, threshold } = args;
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)) {
if (!getShapeForElement(element)) { if (!getShapeForElement(element)) {
return false; 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 ( if (
x < x1 - lineThreshold || !isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
y < y1 - lineThreshold || !isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
x > x2 + lineThreshold ||
y > y2 + lineThreshold
) { ) {
return false; return false;
} }
const [relX, relY] = GAPoint.toTuple(point);
const relX = x - element.x; const shape = getShapeForElement(element) as Drawable[];
const relY = y - element.y;
if (isElementDraggableFromInside(element, appState)) { if (args.check === isInsideCheck) {
const hit = shape.some((subshape) => const hit = shape.some((subshape) =>
hitTestCurveInside(subshape, relX, relY, lineThreshold), hitTestCurveInside(subshape, relX, relY, threshold),
); );
if (hit) { if (hit) {
return true; return true;
} }
} }
// hit thest all "subshapes" of the linear element // hit test all "subshapes" of the linear element
return shape.some((subshape) => 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") { // Returns:
console.warn("This should not happen, we need to investigate why it does."); // 1. the point relative to the elements (x, y) position
return false; // 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 = ( const pointInBezierEquation = (

View File

@ -1,20 +1,25 @@
import { NonDeletedExcalidrawElement } from "./types"; import { SHAPES } from "../shapes";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { SHAPES } from "../shapes";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types";
export const dragSelectedElements = ( export const dragSelectedElements = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
scene: Scene,
) => { ) => {
const [x1, y1] = getCommonBounds(selectedElements); const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(element, {
x: pointerX + element.x - x1, x: element.x + offset.x,
y: pointerY + element.y - y1, y: element.y + offset.y,
}); });
updateBoundElements(element, { simultaneouslyUpdated: selectedElements });
}); });
}; };

View File

@ -1,10 +1,14 @@
import { ExcalidrawElement, PointerType } from "./types"; import { ExcalidrawElement, PointerType } from "./types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation"; 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 } = { const handleSizes: { [k in PointerType]: number } = {
mouse: 8, mouse: 8,
pen: 16, pen: 16,
@ -61,12 +65,12 @@ const generateHandler = (
}; };
export const handlerRectanglesFromCoords = ( export const handlerRectanglesFromCoords = (
[x1, y1, x2, y2]: [number, number, number, number], [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
zoom: number, zoom: number,
pointerType: PointerType = "mouse", pointerType: PointerType = "mouse",
omitSides: { [T in Sides]?: boolean } = {}, omitSides: { [T in Sides]?: boolean } = {},
): Partial<{ [T in Sides]: [number, number, number, number] }> => { ): Handlers => {
const size = handleSizes[pointerType]; const size = handleSizes[pointerType];
const handlerWidth = size / zoom; const handlerWidth = size / zoom;
const handlerHeight = size / zoom; const handlerHeight = size / zoom;

View File

@ -2,6 +2,8 @@ import {
NonDeleted, NonDeleted,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawElement, ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
} from "./types"; } from "./types";
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math"; import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
@ -11,6 +13,13 @@ import { mutateElement } from "./mutateElement";
import { SceneHistory } from "../history"; import { SceneHistory } from "../history";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
export class LinearElementEditor { export class LinearElementEditor {
public elementId: ExcalidrawElement["id"] & { public elementId: ExcalidrawElement["id"] & {
@ -21,6 +30,8 @@ export class LinearElementEditor {
public isDragging: boolean; public isDragging: boolean;
public lastUncommittedPoint: Point | null; public lastUncommittedPoint: Point | null;
public pointerOffset: { x: number; y: number }; public pointerOffset: { x: number; y: number };
public startBindingElement: ExcalidrawBindableElement | null | "keep";
public endBindingElement: ExcalidrawBindableElement | null | "keep";
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) { constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
@ -33,6 +44,8 @@ export class LinearElementEditor {
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
this.isDragging = false; this.isDragging = false;
this.pointerOffset = { x: 0, y: 0 }; 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"], setState: React.Component<any, AppState>["setState"],
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
) => void,
): boolean { ): boolean {
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return false; return false;
@ -88,13 +105,18 @@ export class LinearElementEditor {
appState.gridSize, appState.gridSize,
); );
LinearElementEditor.movePoint(element, activePointIndex, newPoint); LinearElementEditor.movePoint(element, activePointIndex, newPoint);
if (isBindingElement(element)) {
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
}
return true; return true;
} }
return false; return false;
} }
static handlePointerUp( static handlePointerUp(
event: PointerEvent,
editingLinearElement: LinearElementEditor, editingLinearElement: LinearElementEditor,
appState: AppState,
): LinearElementEditor { ): LinearElementEditor {
const { elementId, activePointIndex, isDragging } = editingLinearElement; const { elementId, activePointIndex, isDragging } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
@ -102,12 +124,12 @@ export class LinearElementEditor {
return editingLinearElement; return editingLinearElement;
} }
let binding = {};
if ( if (
isDragging && isDragging &&
(activePointIndex === 0 || (activePointIndex === 0 || activePointIndex === element.points.length - 1)
activePointIndex === element.points.length - 1) &&
isPathALoop(element.points)
) { ) {
if (isPathALoop(element.points)) {
LinearElementEditor.movePoint( LinearElementEditor.movePoint(
element, element,
activePointIndex, activePointIndex,
@ -116,8 +138,26 @@ export class LinearElementEditor {
: element.points[0], : element.points[0],
); );
} }
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
activePointIndex!,
),
),
Scene.getScene(element)!,
)
: null;
binding = {
[activePointIndex === 0
? "startBindingElement"
: "endBindingElement"]: bindingElement,
};
}
return { return {
...editingLinearElement, ...editingLinearElement,
...binding,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
}; };
@ -128,8 +168,7 @@ export class LinearElementEditor {
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"], setState: React.Component<any, AppState>["setState"],
history: SceneHistory, history: SceneHistory,
scenePointerX: number, scenePointer: { x: number; y: number },
scenePointerY: number,
): { ): {
didAddPoint: boolean; didAddPoint: boolean;
hitElement: ExcalidrawElement | null; hitElement: ExcalidrawElement | null;
@ -151,14 +190,14 @@ export class LinearElementEditor {
} }
if (event.altKey) { if (event.altKey) {
if (!appState.editingLinearElement.lastUncommittedPoint) { if (appState.editingLinearElement.lastUncommittedPoint == null) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
LinearElementEditor.createPointAt( LinearElementEditor.createPointAt(
element, element,
scenePointerX, scenePointer.x,
scenePointerY, scenePointer.y,
appState.gridSize, appState.gridSize,
), ),
], ],
@ -170,6 +209,10 @@ export class LinearElementEditor {
...appState.editingLinearElement, ...appState.editingLinearElement,
activePointIndex: element.points.length - 1, activePointIndex: element.points.length - 1,
lastUncommittedPoint: null, lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
}, },
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
@ -179,14 +222,31 @@ export class LinearElementEditor {
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element, element,
appState.zoom, appState.zoom,
scenePointerX, scenePointer.x,
scenePointerY, scenePointer.y,
); );
// if we clicked on a point, set the element as hitElement otherwise // if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area // it would get deselected if the point is outside the hitbox area
if (clickedPointIndex > -1) { if (clickedPointIndex > -1) {
ret.hitElement = element; 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); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -208,8 +268,8 @@ export class LinearElementEditor {
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null, activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
pointerOffset: targetPoint pointerOffset: targetPoint
? { ? {
x: scenePointerX - targetPoint[0], x: scenePointer.x - targetPoint[0],
y: scenePointerY - targetPoint[1], y: scenePointer.y - targetPoint[1],
} }
: { x: 0, y: 0 }, : { x: 0, y: 0 },
}, },
@ -237,7 +297,7 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoint(element, points.length - 1, "delete"); LinearElementEditor.movePoint(element, points.length - 1, "delete");
} }
return editingLinearElement; return { ...editingLinearElement, lastUncommittedPoint: null };
} }
const newPoint = LinearElementEditor.createPointAt( 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( static getPointIndexUnderCursor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
zoom: AppState["zoom"], 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( static movePoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndex: number | "new", pointIndex: number | "new",
targetPosition: Point | "delete", targetPosition: Point | "delete",
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) { ) {
const { points } = element; const { points } = element;
@ -412,6 +519,7 @@ export class LinearElementEditor {
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, { mutateElement(element, {
...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotated[0],
y: element.y + rotated[1], y: element.y + rotated[1],

View File

@ -24,6 +24,7 @@ type ElementConstructorOpts = MarkOptional<
| "height" | "height"
| "angle" | "angle"
| "groupIds" | "groupIds"
| "boundElementIds"
| "seed" | "seed"
| "version" | "version"
| "versionNonce" | "versionNonce"
@ -45,6 +46,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0, height = 0,
angle = 0, angle = 0,
groupIds = [], groupIds = [],
boundElementIds = null,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => ({ ) => ({
@ -67,6 +69,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
version: rest.version || 1, version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElementIds,
}); });
export const newElement = ( export const newElement = (
@ -215,6 +218,8 @@ export const newLinearElement = (
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: [], points: [],
lastCommittedPoint: null, lastCommittedPoint: null,
startBinding: null,
endBinding: null,
}; };
}; };

View File

@ -22,6 +22,7 @@ import {
normalizeResizeHandle, normalizeResizeHandle,
} from "./resizeTest"; } from "./resizeTest";
import { measureText, getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding";
const normalizeAngle = (angle: number): number => { const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
@ -32,6 +33,7 @@ const normalizeAngle = (angle: number): number => {
type ResizeTestType = ReturnType<typeof resizeTest>; type ResizeTestType = ReturnType<typeof resizeTest>;
// Returns true when a resize (scaling/rotation) happened
export const resizeElements = ( export const resizeElements = (
resizeHandle: ResizeTestType, resizeHandle: ResizeTestType,
setResizeHandle: (nextResizeHandle: ResizeTestType) => void, setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
@ -55,6 +57,7 @@ export const resizeElements = (
pointerY, pointerY,
isRotateWithDiscreteAngle, isRotateWithDiscreteAngle,
); );
updateBoundElements(element);
} else if ( } else if (
isLinearElement(element) && isLinearElement(element) &&
element.points.length === 2 && element.points.length === 2 &&
@ -404,6 +407,9 @@ const resizeSingleElement = (
const deltaX2 = (x2 - nextX2) / 2; const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2; const deltaY2 = (y2 - nextY2) / 2;
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight); const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
updateBoundElements(element, {
newSize: { width: nextWidth, height: nextHeight },
});
const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords( const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
{ {
...element, ...element,
@ -530,6 +536,10 @@ const resizeMultipleElements = (
} }
const origCoords = getElementAbsoluteCoords(element); const origCoords = getElementAbsoluteCoords(element);
const rescaledPoints = rescalePointsInElement(element, width, height); const rescaledPoints = rescalePointsInElement(element, width, height);
updateBoundElements(element, {
newSize: { width, height },
simultaneouslyUpdated: elements,
});
const finalCoords = getResizedElementAbsoluteCoords( const finalCoords = getResizedElementAbsoluteCoords(
{ {
...element, ...element,

View File

@ -2,6 +2,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawBindableElement,
} from "./types"; } from "./types";
export const isTextElement = ( export const isTextElement = (
@ -13,11 +14,38 @@ export const isTextElement = (
export const isLinearElement = ( export const isLinearElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => { ): 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 ( return (
element != null && element != null &&
(element.type === "arrow" || (element.type === "rectangle" ||
element.type === "line" || element.type === "diamond" ||
element.type === "draw") element.type === "ellipse" ||
element.type === "text")
); );
}; };

View File

@ -22,19 +22,33 @@ type _ExcalidrawElementBase = Readonly<{
versionNonce: number; versionNonce: number;
isDeleted: boolean; isDeleted: boolean;
groupIds: readonly GroupId[]; groupIds: readonly GroupId[];
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
type: "selection"; 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. * These are elements that don't have any additional properties.
*/ */
export type ExcalidrawGenericElement = export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement | ExcalidrawSelectionElement
| (_ExcalidrawElementBase & { | ExcalidrawRectangleElement
type: "rectangle" | "diamond" | "ellipse"; | ExcalidrawDiamondElement
}); | ExcalidrawEllipseElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
@ -63,11 +77,25 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
}>; }>;
export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
};
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "arrow" | "line" | "draw"; type: "arrow" | "line" | "draw";
points: readonly Point[]; points: readonly Point[];
lastCommittedPoint: Point | null; lastCommittedPoint: Point | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
}>; }>;
export type PointerType = "mouse" | "pen" | "touch"; export type PointerType = "mouse" | "pen" | "touch";

340
src/ga.ts Normal file
View 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
View 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
View 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
View 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
View 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)));
}

View File

@ -2,45 +2,6 @@ import { Point } from "./types";
import { LINE_CONFIRM_THRESHOLD } from "./constants"; import { LINE_CONFIRM_THRESHOLD } from "./constants";
import { ExcalidrawLinearElement } from "./element/types"; 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 = ( export const rotate = (
x1: number, x1: number,
y1: number, y1: number,
@ -230,6 +191,10 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
return Math.hypot(xd, yd); 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 // Checks if the first and last point are close enough
// to be considered a loop // to be considered a loop
export const isPathALoop = ( export const isPathALoop = (
@ -265,9 +230,9 @@ export const isPointInPolygon = (
for (let i = 0; i < vertices; i++) { for (let i = 0; i < vertices; i++) {
const current = points[i]; const current = points[i];
const next = points[(i + 1) % vertices]; const next = points[(i + 1) % vertices];
if (doIntersect(current, next, p, extreme)) { if (doSegmentsIntersect(current, next, p, extreme)) {
if (orientation(current, p, next) === 0) { if (orderedColinearOrientation(current, p, next) === 0) {
return onSegment(current, p, next); return isPointWithinBounds(current, p, next);
} }
count++; count++;
} }
@ -276,8 +241,9 @@ export const isPointInPolygon = (
return count % 2 === 1; return count % 2 === 1;
}; };
// Check if q lies on the line segment pr // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
const onSegment = (p: Point, q: Point, r: Point) => { // This is an approximation to "does `q` lie on a segment `pr`" check.
const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
return ( return (
q[0] <= Math.max(p[0], r[0]) && q[0] <= Math.max(p[0], r[0]) &&
q[0] >= Math.min(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 // 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 // 1 if Clockwise
// 2 if counterclickwise // 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]); const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
if (val === 0) { if (val === 0) {
return 0; return 0;
@ -299,33 +265,33 @@ const orientation = (p: Point, q: Point, r: Point) => {
}; };
// Check is p1q1 intersects with p2q2 // Check is p1q1 intersects with p2q2
const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
const o1 = orientation(p1, q1, p2); const o1 = orderedColinearOrientation(p1, q1, p2);
const o2 = orientation(p1, q1, q2); const o2 = orderedColinearOrientation(p1, q1, q2);
const o3 = orientation(p2, q2, p1); const o3 = orderedColinearOrientation(p2, q2, p1);
const o4 = orientation(p2, q2, q1); const o4 = orderedColinearOrientation(p2, q2, q1);
if (o1 !== o2 && o3 !== o4) { if (o1 !== o2 && o3 !== o4) {
return true; return true;
} }
// p1, q1 and p2 are colinear and p2 lies on segment p1q1 // 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; return true;
} }
// p1, q1 and p2 are colinear and q2 lies on segment p1q1 // 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; return true;
} }
// p2, q2 and p1 are colinear and p1 lies on segment p2q2 // 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; return true;
} }
// p2, q2 and q1 are colinear and q1 lies on segment p2q2 // 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; return true;
} }

View File

@ -9,6 +9,7 @@ import {
ExcalidrawLinearElement, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
GroupId, GroupId,
ExcalidrawBindableElement,
} from "../element/types"; } from "../element/types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -36,6 +37,13 @@ import {
getSelectedGroupIds, getSelectedGroupIds,
getElementsInGroup, getElementsInGroup,
} from "../groups"; } 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>; type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
@ -48,7 +56,7 @@ const strokeRectWithRotation = (
cx: number, cx: number,
cy: number, cy: number,
angle: number, angle: number,
fill?: boolean, fill: boolean = false,
) => { ) => {
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(angle); context.rotate(angle);
@ -60,15 +68,48 @@ const strokeRectWithRotation = (
context.translate(-cx, -cy); context.translate(-cx, -cy);
}; };
const strokeCircle = ( const strokeDiamondWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
x: number,
y: number,
width: number, width: number,
height: 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.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.fill();
context.stroke(); context.stroke();
}; };
@ -116,12 +157,11 @@ const renderLinearPointHandles = (
? "rgba(255, 127, 127, 0.9)" ? "rgba(255, 127, 127, 0.9)"
: "rgba(255, 255, 255, 0.9)"; : "rgba(255, 255, 255, 0.9)";
const { POINT_HANDLE_SIZE } = LinearElementEditor; const { POINT_HANDLE_SIZE } = LinearElementEditor;
strokeCircle( fillCircle(
context, context,
point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom, point[0],
point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom, point[1],
POINT_HANDLE_SIZE / sceneState.zoom, POINT_HANDLE_SIZE / 2 / sceneState.zoom,
POINT_HANDLE_SIZE / 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 // Paint selected elements
if ( if (
renderSelection && renderSelection &&
!appState.multiElement && !appState.multiElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
context.translate(sceneState.scrollX, sceneState.scrollY);
const selections = elements.reduce((acc, element) => { const selections = elements.reduce((acc, element) => {
const selectionColors = []; const selectionColors = [];
// local user // local user
@ -310,99 +356,28 @@ export const renderScene = (
addSelectionForGroupId(appState.editingGroupId); addSelectionForGroupId(appState.editingGroupId);
} }
selections.forEach( selections.forEach((selection) =>
({ renderSelectionBorder(context, sceneState, selection),
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,
); );
}
context.lineDashOffset = lineDashOffset;
context.strokeStyle = strokeStyle;
context.lineWidth = lineWidth;
context.setLineDash(initialLineDash);
},
);
context.translate(-sceneState.scrollX, -sceneState.scrollY);
const locallySelectedElements = getSelectedElements(elements, appState); const locallySelectedElements = getSelectedElements(elements, appState);
// Paint resize handlers // Paint resize handlers
if (locallySelectedElements.length === 1) {
context.translate(sceneState.scrollX, sceneState.scrollY); context.translate(sceneState.scrollX, sceneState.scrollY);
if (locallySelectedElements.length === 1) {
context.fillStyle = oc.white; context.fillStyle = oc.white;
const handlers = handlerRectangles( const handlers = handlerRectangles(
locallySelectedElements[0], locallySelectedElements[0],
sceneState.zoom, sceneState.zoom,
); );
Object.keys(handlers).forEach((key) => { renderHandlers(
const handler = handlers[key as HandlerRectanglesRet];
if (handler !== undefined) {
const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom;
if (key === "rotation") {
strokeCircle(
context, context,
handler[0], sceneState,
handler[1], handlers,
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,
locallySelectedElements[0].angle, locallySelectedElements[0].angle,
true, // fill before stroke
); );
}
context.lineWidth = lineWidth;
}
});
context.translate(-sceneState.scrollX, -sceneState.scrollY);
} else if (locallySelectedElements.length > 1 && !appState.isRotating) { } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
const dashedLinePadding = 4 / sceneState.zoom; const dashedLinePadding = 4 / sceneState.zoom;
context.translate(sceneState.scrollX, sceneState.scrollY);
context.fillStyle = oc.white; context.fillStyle = oc.white;
const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
const initialLineDash = context.getLineDash(); const initialLineDash = context.getLineDash();
@ -428,38 +403,10 @@ export const renderScene = (
undefined, undefined,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
); );
Object.keys(handlers).forEach((key) => { renderHandlers(context, sceneState, handlers, 0);
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
);
} }
context.lineWidth = lineWidth;
}
});
context.translate(-sceneState.scrollX, -sceneState.scrollY); context.translate(-sceneState.scrollX, -sceneState.scrollY);
} }
}
// Reset zoom // Reset zoom
context.scale(1 / sceneState.zoom, 1 / sceneState.zoom); context.scale(1 / sceneState.zoom, 1 / sceneState.zoom);
@ -598,6 +545,207 @@ export const renderScene = (
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; 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 = ( const isVisibleElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
viewportWidth: number, viewportWidth: number,

View File

@ -52,10 +52,12 @@ class Scene {
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
// TODO: getAllElementsIncludingDeleted
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this.elements; return this.elements;
} }
// TODO: getAllNonDeletedElements
getElements(): readonly NonDeletedExcalidrawElement[] { getElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements; return this.nonDeletedElements;
} }
@ -74,6 +76,20 @@ class Scene {
return null; 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[]) { replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this.elements = nextElements; this.elements = nextElements;
this.elementsMap.clear(); this.elementsMap.clear();

View File

@ -3,8 +3,7 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { getElementAbsoluteCoords, hitTest } from "../element"; import { getElementAbsoluteCoords } from "../element";
import { AppState } from "../types";
export const hasBackground = (type: string) => export const hasBackground = (type: string) =>
type === "rectangle" || type === "rectangle" ||
@ -25,19 +24,17 @@ export const hasText = (type: string) => type === "text";
export const getElementAtPosition = ( export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
x: number,
y: number,
zoom: number,
) => { ) => {
let hitElement = null; let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array) // 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) { for (let i = elements.length - 1; i >= 0; --i) {
if (elements[i].isDeleted) { const element = elements[i];
if (element.isDeleted) {
continue; continue;
} }
if (hitTest(elements[i], appState, x, y, zoom)) { if (isAtPositionFn(element)) {
hitElement = elements[i]; hitElement = element;
break; break;
} }
} }

View File

@ -6,6 +6,8 @@ exports[`add element to the scene when pointer dragging long enough arrow 2`] =
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -25,6 +27,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
@ -43,6 +46,7 @@ exports[`add element to the scene when pointer dragging long enough diamond 2`]
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -69,6 +73,7 @@ exports[`add element to the scene when pointer dragging long enough ellipse 2`]
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -93,6 +98,8 @@ exports[`add element to the scene when pointer dragging long enough line 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -112,6 +119,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
@ -130,6 +138,7 @@ exports[`add element to the scene when pointer dragging long enough rectangle 2`
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,

View File

@ -4,6 +4,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -28,6 +29,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 2`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -52,6 +54,7 @@ exports[`move element rectangle 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,

View File

@ -4,6 +4,8 @@ exports[`multi point mode in linear elements arrow 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
@ -30,6 +32,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
@ -46,6 +49,8 @@ exports[`multi point mode in linear elements line 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
@ -72,6 +77,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ exports[`resize element rectangle 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -28,6 +29,7 @@ exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] =
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,

View File

@ -4,6 +4,8 @@ exports[`select single element on the scene arrow 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -23,6 +25,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
@ -39,6 +42,8 @@ exports[`select single element on the scene arrow escape 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -58,6 +63,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
@ -74,6 +80,7 @@ exports[`select single element on the scene diamond 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -98,6 +105,7 @@ exports[`select single element on the scene ellipse 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
@ -122,6 +130,7 @@ exports[`select single element on the scene rectangle 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,

View 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)));
});
});
});

View File

@ -83,7 +83,9 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 }); fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
fireEvent.pointerUp(canvas); 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.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);

View File

@ -259,40 +259,40 @@ afterEach(() => {
describe("regression tests", () => { describe("regression tests", () => {
it("draw every type of shape", () => { it("draw every type of shape", () => {
clickTool("rectangle"); clickTool("rectangle");
mouse.down(10, 10); mouse.down(10, -10);
mouse.up(10, 10); mouse.up(20, 10);
clickTool("diamond"); clickTool("diamond");
mouse.down(10, -10); mouse.down(10, -10);
mouse.up(10, 10); mouse.up(20, 10);
clickTool("ellipse"); clickTool("ellipse");
mouse.down(10, -10); mouse.down(10, -10);
mouse.up(10, 10); mouse.up(20, 10);
clickTool("arrow"); clickTool("arrow");
mouse.down(10, -10); mouse.down(40, -10);
mouse.up(10, 10); mouse.up(50, 10);
clickTool("line"); clickTool("line");
mouse.down(10, -10); mouse.down(40, -10);
mouse.up(10, 10); mouse.up(50, 10);
clickTool("arrow"); clickTool("arrow");
mouse.click(10, -10); mouse.click(40, -10);
mouse.click(10, 10); mouse.click(50, 10);
mouse.click(-10, 10); mouse.click(30, 10);
hotkeyPress("ENTER"); hotkeyPress("ENTER");
clickTool("line"); clickTool("line");
mouse.click(10, -20); mouse.click(40, -20);
mouse.click(10, 10); mouse.click(50, 10);
mouse.click(-10, 10); mouse.click(30, 10);
hotkeyPress("ENTER"); hotkeyPress("ENTER");
clickTool("draw"); clickTool("draw");
mouse.down(10, -20); mouse.down(40, -20);
mouse.up(10, 10); mouse.up(50, 10);
expect(h.elements.map((element) => element.type)).toEqual([ expect(h.elements.map((element) => element.type)).toEqual([
"rectangle", "rectangle",
@ -569,17 +569,17 @@ describe("regression tests", () => {
it("undo/redo drawing an element", () => { it("undo/redo drawing an element", () => {
clickTool("rectangle"); clickTool("rectangle");
mouse.down(10, 10); mouse.down(10, -10);
mouse.up(10, 10); mouse.up(20, 10);
clickTool("rectangle"); clickTool("rectangle");
mouse.down(10, -10); mouse.down(10, 0);
mouse.up(10, 10); mouse.up(30, 20);
clickTool("arrow"); clickTool("arrow");
mouse.click(10, -10); mouse.click(60, -10);
mouse.click(10, 10); mouse.click(60, 10);
mouse.click(-10, 10); mouse.click(40, 10);
hotkeyPress("ENTER"); hotkeyPress("ENTER");
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);

View File

@ -7,11 +7,13 @@ import {
ExcalidrawElement, ExcalidrawElement,
FontFamily, FontFamily,
GroupId, GroupId,
ExcalidrawBindableElement,
} from "./element/types"; } from "./element/types";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
import { SocketUpdateDataSource } from "./data"; import { SocketUpdateDataSource } from "./data";
import { LinearElementEditor } from "./element/linearElementEditor"; import { LinearElementEditor } from "./element/linearElementEditor";
import { SuggestedBinding } from "./element/binding";
export type FlooredNumber = number & { _brand: "FlooredNumber" }; export type FlooredNumber = number & { _brand: "FlooredNumber" };
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -33,6 +35,9 @@ export type AppState = {
resizingElement: NonDeletedExcalidrawElement | null; resizingElement: NonDeletedExcalidrawElement | null;
multiElement: NonDeleted<ExcalidrawLinearElement> | null; multiElement: NonDeleted<ExcalidrawLinearElement> | null;
selectionElement: NonDeletedExcalidrawElement | null; selectionElement: NonDeletedExcalidrawElement | null;
isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[];
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input) // (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null; editingElement: NonDeletedExcalidrawElement | null;

View File

@ -241,7 +241,7 @@ export const isRTL = (text: string) => {
}; };
export function tupleToCoors( export function tupleToCoors(
xyTuple: [number, number], xyTuple: readonly [number, number],
): { x: number; y: number } { ): { x: number; y: number } {
const [x, y] = xyTuple; const [x, y] = xyTuple;
return { x, y }; return { x, y };