Allow binding linear elements to other elements (#1899)
* Refactor: simplify linear element type * Refactor: dedupe scrollbar handling * First step towards binding - establish relationship and basic test for dragged lines * Refactor: use zoom from appstate * Refactor: generalize getElementAtPosition * Only consider bindable elements in hit test * Refactor: pull out pieces of hit test for reuse later * Refactor: pull out diamond from hit test for reuse later * Refactor: pull out text from hit test for reuse later * Suggest binding when hovering * Give shapes in regression test real size * Give shapes in undo/redo test real size * Keep bound element highlighted * Show binding suggestion for multi-point elements * Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements * Use Id instead of ID * Improve boundary offset for non-squarish elements * Fix localStorage for binding on linear elements * Simplify dragging code and fix elements bound twice to the same shape * Fix binding for rectangles * Bind both ends at the end of the linear element creation, needed for focus points * wip * Refactor: Renames and reshapes for next commit * Calculate and store focus points and gaps, but dont use them yet * Focus points for rectangles * Dont blow up when canceling linear element * Stop suggesting binding when a non-compatible tool is selected * Clean up collision code * Using Geometric Algebra for hit tests * Correct binding for all shapes * Constant gap around polygon corners * Fix rotation handling * Generalize update and fix hit test for rotated elements * Handle rotation realtime * Handle scaling * Remove vibration when moving bound and binding element together * Handle simultenous scaling * Allow binding and unbinding when editing linear elements * Dont delete binding when the end point wasnt touched * Bind on enter/escape when editing * Support multiple suggested bindable elements in preparation for supporting linear elements dragging * Update binding when moving linear elements * Update binding when resizing linear elements * Dont re-render UI on binding hints * Update both ends when one is moved * Use distance instead of focus point for binding * Complicated approach for posterity, ignore this commit * Revert the complicated approach * Better focus point strategy, working for all shapes * Update snapshots * Dont break binding gap when mirroring shape * Dont break binding gap when grid mode pushes it inside * Dont bind draw elements * Support alt duplication * Fix alt duplication to * Support cmd+D duplication * All copy mechanisms are supported * Allow binding shapes to arrows, having arrows created first * Prevent arrows from disappearing for ellipses * Better binding suggestion highlight for shapes * Dont suggest second binding for simple elements when editing or moving them * Dont steal already bound linear elements when moving shapes * Fix highlighting diamonds and more precisely highlight other shapes * Highlight linear element edges for binding * Highlight text binding too * Handle deletion * Dont suggest second binding for simple linear elements when creating them * Dont highlight bound element during creation * Fix binding for rotated linear elements * Fix collision check for ellipses * Dont show suggested bindings for selected pairs * Bind multi-point linear elements when the tool is switched - important for mobile * Handle unbinding one of two bound edges correctly * Rename boundElement in state to startBoundElement * Dont double account for zoom when rendering binding highlight * Fix rendering of edited linear element point handles * Suggest binding when adding new point to a linear element * Bind when adding a new point to a linear element and dont unbind when moving middle elements * Handle deleting points * Add cmd modifier key to disable binding * Use state for enabling binding, fix not binding for linear elements during creation * Drop support for binding lines, only arrows are bindable * Reset binding mode on blur * Fix not binding lines
This commit is contained in:
parent
5f195694ee
commit
26f67d27ec
@ -11,6 +11,7 @@ import { AppState } from "../types";
|
|||||||
import { newElementWith } from "../element/mutateElement";
|
import { 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);
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
@ -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(
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
),
|
||||||
)
|
|
||||||
.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
fixBindingsAfterDeletion(elements, deletedElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
@ -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 },
|
||||||
|
@ -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(
|
||||||
x: element.x + dx - minX,
|
this.state.editingGroupId,
|
||||||
y: element.y + dy - minY,
|
groupIdMap,
|
||||||
});
|
element,
|
||||||
|
{
|
||||||
|
x: element.x + dx - minX,
|
||||||
|
y: element.y + dy - minY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
|
return newElement;
|
||||||
});
|
});
|
||||||
this.scene.replaceAllElements([
|
const nextElements = [
|
||||||
...this.scene.getElementsIncludingDeleted(),
|
...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();
|
||||||
}
|
}
|
||||||
|
const pointerCoords = viewportCoordsToSceneCoords(
|
||||||
|
childEvent,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
window.devicePixelRatio,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!pointerDownState.drag.hasOccurred &&
|
!pointerDownState.drag.hasOccurred &&
|
||||||
draggingElement &&
|
draggingElement &&
|
||||||
!multiElement
|
!multiElement
|
||||||
) {
|
) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
|
||||||
childEvent,
|
|
||||||
this.state,
|
|
||||||
this.canvas,
|
|
||||||
window.devicePixelRatio,
|
|
||||||
);
|
|
||||||
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: [
|
||||||
|
@ -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);
|
||||||
|
@ -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
674
src/element/binding.ts
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
import {
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
NonDeleted,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
PointBinding,
|
||||||
|
ExcalidrawElement,
|
||||||
|
} from "./types";
|
||||||
|
import { getElementAtPosition } from "../scene";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { isBindableElement, isBindingElement } from "./typeChecks";
|
||||||
|
import {
|
||||||
|
bindingBorderTest,
|
||||||
|
distanceToBindableElement,
|
||||||
|
maxBindingGap,
|
||||||
|
determineFocusDistance,
|
||||||
|
intersectElementWithLine,
|
||||||
|
determineFocusPoint,
|
||||||
|
} from "./collision";
|
||||||
|
import { mutateElement } from "./mutateElement";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { tupleToCoors } from "../utils";
|
||||||
|
|
||||||
|
export type SuggestedBinding =
|
||||||
|
| NonDeleted<ExcalidrawBindableElement>
|
||||||
|
| SuggestedPointBinding;
|
||||||
|
|
||||||
|
export type SuggestedPointBinding = [
|
||||||
|
NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
"start" | "end" | "both",
|
||||||
|
NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isBindingEnabled = (appState: AppState): boolean => {
|
||||||
|
return appState.isBindingEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bindOrUnbindLinearElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||||
|
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||||
|
): void => {
|
||||||
|
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
|
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
|
bindOrUnbindLinearElementEdge(
|
||||||
|
linearElement,
|
||||||
|
startBindingElement,
|
||||||
|
"start",
|
||||||
|
boundToElementIds,
|
||||||
|
unboundFromElementIds,
|
||||||
|
);
|
||||||
|
bindOrUnbindLinearElementEdge(
|
||||||
|
linearElement,
|
||||||
|
endBindingElement,
|
||||||
|
"end",
|
||||||
|
boundToElementIds,
|
||||||
|
unboundFromElementIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
|
(id) => !boundToElementIds.has(id),
|
||||||
|
);
|
||||||
|
Scene.getScene(linearElement)!
|
||||||
|
.getNonDeletedElements(onlyUnbound)
|
||||||
|
.forEach((element) => {
|
||||||
|
mutateElement(element, {
|
||||||
|
boundElementIds: element.boundElementIds?.filter(
|
||||||
|
(id) => id !== linearElement.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindOrUnbindLinearElementEdge = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
bindableElement: ExcalidrawBindableElement | null | "keep",
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
// Is mutated
|
||||||
|
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
|
// Is mutated
|
||||||
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
|
): void => {
|
||||||
|
if (bindableElement !== "keep") {
|
||||||
|
if (bindableElement != null) {
|
||||||
|
bindLinearElement(linearElement, bindableElement, startOrEnd);
|
||||||
|
boundToElementIds.add(bindableElement.id);
|
||||||
|
} else {
|
||||||
|
const unbound = unbindLinearElement(linearElement, startOrEnd);
|
||||||
|
if (unbound != null) {
|
||||||
|
unboundFromElementIds.add(unbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bindOrUnbindSelectedElements = (
|
||||||
|
elements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
): void => {
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
element,
|
||||||
|
getElligibleElementForBindingElement(element, "start"),
|
||||||
|
getElligibleElementForBindingElement(element, "end"),
|
||||||
|
);
|
||||||
|
} else if (isBindableElement(element)) {
|
||||||
|
maybeBindBindableElement(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maybeBindBindableElement = (
|
||||||
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
): void => {
|
||||||
|
getElligibleElementsForBindableElementAndWhere(
|
||||||
|
bindableElement,
|
||||||
|
).forEach(([linearElement, where]) =>
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
linearElement,
|
||||||
|
where === "end" ? "keep" : bindableElement,
|
||||||
|
where === "start" ? "keep" : bindableElement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maybeBindLinearElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
|
scene: Scene,
|
||||||
|
pointerCoords: { x: number; y: number },
|
||||||
|
): void => {
|
||||||
|
if (appState.startBoundElement != null) {
|
||||||
|
bindLinearElement(linearElement, appState.startBoundElement, "start");
|
||||||
|
}
|
||||||
|
const hoveredElement = getHoveredElementForBinding(pointerCoords, scene);
|
||||||
|
if (hoveredElement != null) {
|
||||||
|
bindLinearElement(linearElement, hoveredElement, "end");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindLinearElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): void => {
|
||||||
|
if (
|
||||||
|
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
|
linearElement,
|
||||||
|
hoveredElement,
|
||||||
|
startOrEnd,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutateElement(linearElement, {
|
||||||
|
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
||||||
|
elementId: hoveredElement.id,
|
||||||
|
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
||||||
|
} as PointBinding,
|
||||||
|
});
|
||||||
|
mutateElement(hoveredElement, {
|
||||||
|
boundElementIds: [
|
||||||
|
...new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't bind both ends of a simple segment
|
||||||
|
const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
bindableElement: ExcalidrawBindableElement,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): boolean => {
|
||||||
|
const otherBinding =
|
||||||
|
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
|
||||||
|
return isLinearElementSimpleAndAlreadyBound(
|
||||||
|
linearElement,
|
||||||
|
otherBinding?.elementId,
|
||||||
|
bindableElement,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLinearElementSimpleAndAlreadyBound = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
|
||||||
|
bindableElement: ExcalidrawBindableElement,
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unbindLinearElements = (
|
||||||
|
elements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
): void => {
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
bindOrUnbindLinearElement(element, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindLinearElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): ExcalidrawBindableElement["id"] | null => {
|
||||||
|
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
|
||||||
|
const binding = linearElement[field];
|
||||||
|
if (binding == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
mutateElement(linearElement, { [field]: null });
|
||||||
|
return binding.elementId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHoveredElementForBinding = (
|
||||||
|
pointerCoords: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
},
|
||||||
|
scene: Scene,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
|
const hoveredElement = getElementAtPosition(
|
||||||
|
scene.getElements(),
|
||||||
|
(element) =>
|
||||||
|
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
||||||
|
);
|
||||||
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateFocusAndGap = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): { focus: number; gap: number } => {
|
||||||
|
const direction = startOrEnd === "start" ? -1 : 1;
|
||||||
|
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||||
|
const adjacentPointIndex = edgePointIndex - direction;
|
||||||
|
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
linearElement,
|
||||||
|
edgePointIndex,
|
||||||
|
);
|
||||||
|
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
linearElement,
|
||||||
|
adjacentPointIndex,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||||
|
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supports translating, rotating and scaling `changedElement` with bound
|
||||||
|
// linear elements.
|
||||||
|
// Because scaling involves moving the focus points as well, it is
|
||||||
|
// done before the `changedElement` is updated, and the `newSize` is passed
|
||||||
|
// in explicitly.
|
||||||
|
export const updateBoundElements = (
|
||||||
|
changedElement: NonDeletedExcalidrawElement,
|
||||||
|
options?: {
|
||||||
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
|
newSize?: { width: number; height: number };
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const boundElementIds = changedElement.boundElementIds ?? [];
|
||||||
|
if (boundElementIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||||
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
|
simultaneouslyUpdated,
|
||||||
|
);
|
||||||
|
(Scene.getScene(changedElement)!.getNonDeletedElements(
|
||||||
|
boundElementIds,
|
||||||
|
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
|
||||||
|
const bindableElement = changedElement as ExcalidrawBindableElement;
|
||||||
|
// In case the boundElementIds are stale
|
||||||
|
if (!doesNeedUpdate(linearElement, bindableElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startBinding = maybeCalculateNewGapWhenScaling(
|
||||||
|
bindableElement,
|
||||||
|
linearElement.startBinding,
|
||||||
|
newSize,
|
||||||
|
);
|
||||||
|
const endBinding = maybeCalculateNewGapWhenScaling(
|
||||||
|
bindableElement,
|
||||||
|
linearElement.endBinding,
|
||||||
|
newSize,
|
||||||
|
);
|
||||||
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
|
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
|
||||||
|
mutateElement(linearElement, { startBinding, endBinding });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateBoundPoint(
|
||||||
|
linearElement,
|
||||||
|
"start",
|
||||||
|
startBinding,
|
||||||
|
changedElement as ExcalidrawBindableElement,
|
||||||
|
);
|
||||||
|
updateBoundPoint(
|
||||||
|
linearElement,
|
||||||
|
"end",
|
||||||
|
endBinding,
|
||||||
|
changedElement as ExcalidrawBindableElement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const doesNeedUpdate = (
|
||||||
|
boundElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
changedElement: ExcalidrawBindableElement,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
boundElement.startBinding?.elementId === changedElement.id ||
|
||||||
|
boundElement.endBinding?.elementId === changedElement.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSimultaneouslyUpdatedElementIds = (
|
||||||
|
simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
|
||||||
|
): Set<ExcalidrawElement["id"]> => {
|
||||||
|
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBoundPoint = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
binding: PointBinding | null | undefined,
|
||||||
|
changedElement: ExcalidrawBindableElement,
|
||||||
|
): void => {
|
||||||
|
if (
|
||||||
|
binding == null ||
|
||||||
|
// We only need to update the other end if this is a 2 point line element
|
||||||
|
(binding.elementId !== changedElement.id && linearElement.points.length > 2)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bindingElement = Scene.getScene(linearElement)!.getElement(
|
||||||
|
binding.elementId,
|
||||||
|
) as ExcalidrawBindableElement | null;
|
||||||
|
if (bindingElement == null) {
|
||||||
|
// We're not cleaning up after deleted elements atm., so handle this case
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const direction = startOrEnd === "start" ? -1 : 1;
|
||||||
|
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||||
|
const adjacentPointIndex = edgePointIndex - direction;
|
||||||
|
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
linearElement,
|
||||||
|
adjacentPointIndex,
|
||||||
|
);
|
||||||
|
const focusPointAbsolute = determineFocusPoint(
|
||||||
|
bindingElement,
|
||||||
|
binding.focus,
|
||||||
|
adjacentPoint,
|
||||||
|
);
|
||||||
|
let newEdgePoint;
|
||||||
|
// The linear element was not originally pointing inside the bound shape,
|
||||||
|
// we can point directly at the focus point
|
||||||
|
if (binding.gap === 0) {
|
||||||
|
newEdgePoint = focusPointAbsolute;
|
||||||
|
} else {
|
||||||
|
const intersections = intersectElementWithLine(
|
||||||
|
bindingElement,
|
||||||
|
adjacentPoint,
|
||||||
|
focusPointAbsolute,
|
||||||
|
binding.gap,
|
||||||
|
);
|
||||||
|
if (intersections.length === 0) {
|
||||||
|
// This should never happen, since focusPoint should always be
|
||||||
|
// inside the element, but just in case, bail out
|
||||||
|
newEdgePoint = focusPointAbsolute;
|
||||||
|
} else {
|
||||||
|
// Guaranteed to intersect because focusPoint is always inside the shape
|
||||||
|
newEdgePoint = intersections[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinearElementEditor.movePoint(
|
||||||
|
linearElement,
|
||||||
|
edgePointIndex,
|
||||||
|
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
|
||||||
|
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeCalculateNewGapWhenScaling = (
|
||||||
|
changedElement: ExcalidrawBindableElement,
|
||||||
|
currentBinding: PointBinding | null | undefined,
|
||||||
|
newSize: { width: number; height: number } | undefined,
|
||||||
|
): PointBinding | null | undefined => {
|
||||||
|
if (currentBinding == null || newSize == null) {
|
||||||
|
return currentBinding;
|
||||||
|
}
|
||||||
|
const { gap, focus, elementId } = currentBinding;
|
||||||
|
const { width: newWidth, height: newHeight } = newSize;
|
||||||
|
const { width, height } = changedElement;
|
||||||
|
const newGap = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
maxBindingGap(changedElement, newWidth, newHeight),
|
||||||
|
gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { elementId, gap: newGap, focus };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEligibleElementsForBinding = (
|
||||||
|
elements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
): SuggestedBinding[] => {
|
||||||
|
const includedElementIds = new Set(elements.map(({ id }) => id));
|
||||||
|
return elements.flatMap((element) =>
|
||||||
|
isBindingElement(element)
|
||||||
|
? (getElligibleElementsForBindingElement(
|
||||||
|
element as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
).filter(
|
||||||
|
(element) => !includedElementIds.has(element.id),
|
||||||
|
) as SuggestedBinding[])
|
||||||
|
: isBindableElement(element)
|
||||||
|
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
||||||
|
(binding) => !includedElementIds.has(binding[0].id),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElligibleElementsForBindingElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||||
|
return [
|
||||||
|
getElligibleElementForBindingElement(linearElement, "start"),
|
||||||
|
getElligibleElementForBindingElement(linearElement, "end"),
|
||||||
|
].filter(
|
||||||
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
|
element != null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElligibleElementForBindingElement = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
|
return getElligibleElementForBindingElementAtCoors(
|
||||||
|
linearElement,
|
||||||
|
startOrEnd,
|
||||||
|
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getElligibleElementForBindingElementAtCoors = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
pointerCoords: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
},
|
||||||
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
|
const bindableElement = getHoveredElementForBinding(
|
||||||
|
pointerCoords,
|
||||||
|
Scene.getScene(linearElement)!,
|
||||||
|
);
|
||||||
|
if (bindableElement == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Note: We could push this check inside a version of
|
||||||
|
// `getHoveredElementForBinding`, but it's unlikely this is needed.
|
||||||
|
if (
|
||||||
|
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
|
linearElement,
|
||||||
|
bindableElement,
|
||||||
|
startOrEnd,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return bindableElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLinearElementEdgeCoors = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
const index = startOrEnd === "start" ? 0 : -1;
|
||||||
|
return tupleToCoors(
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElligibleElementsForBindableElementAndWhere = (
|
||||||
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
): SuggestedPointBinding[] => {
|
||||||
|
return Scene.getScene(bindableElement)!
|
||||||
|
.getElements()
|
||||||
|
.map((element) => {
|
||||||
|
if (!isBindingElement(element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
||||||
|
element,
|
||||||
|
"start",
|
||||||
|
bindableElement,
|
||||||
|
);
|
||||||
|
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
||||||
|
element,
|
||||||
|
"end",
|
||||||
|
bindableElement,
|
||||||
|
);
|
||||||
|
if (!canBindStart && !canBindEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
element,
|
||||||
|
canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
|
||||||
|
bindableElement,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLinearElementEligibleForNewBindingByBindable = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startOrEnd: "start" | "end",
|
||||||
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||||
|
): boolean => {
|
||||||
|
const existingBinding =
|
||||||
|
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
||||||
|
return (
|
||||||
|
existingBinding == null &&
|
||||||
|
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
|
linearElement,
|
||||||
|
bindableElement,
|
||||||
|
startOrEnd,
|
||||||
|
) &&
|
||||||
|
bindingBorderTest(
|
||||||
|
bindableElement,
|
||||||
|
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// We need to:
|
||||||
|
// 1: Update elements not selected to point to duplicated elements
|
||||||
|
// 2: Update duplicated elements to point to other duplicated elements
|
||||||
|
export const fixBindingsAfterDuplication = (
|
||||||
|
sceneElements: readonly ExcalidrawElement[],
|
||||||
|
oldElements: readonly ExcalidrawElement[],
|
||||||
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
|
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
|
||||||
|
// Only when alt-dragging the new "duplicates" act as the "old", while
|
||||||
|
// the "old" elements act as the "new copy" - essentially working reverse
|
||||||
|
// to the other two.
|
||||||
|
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
|
||||||
|
): void => {
|
||||||
|
// First collect all the binding/bindable elements, so we only update
|
||||||
|
// each once, regardless of whether they were duplicated or not.
|
||||||
|
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||||
|
oldElements.forEach((oldElement) => {
|
||||||
|
const { boundElementIds } = oldElement;
|
||||||
|
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||||
|
boundElementIds.forEach((boundElementId) => {
|
||||||
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
|
||||||
|
allBoundElementIds.add(boundElementId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||||
|
}
|
||||||
|
if (isBindingElement(oldElement)) {
|
||||||
|
if (oldElement.startBinding != null) {
|
||||||
|
const { elementId } = oldElement.startBinding;
|
||||||
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
||||||
|
allBindableElementIds.add(elementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldElement.endBinding != null) {
|
||||||
|
const { elementId } = oldElement.endBinding;
|
||||||
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
||||||
|
allBindableElementIds.add(elementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldElement.startBinding != null || oldElement.endBinding != null) {
|
||||||
|
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the linear elements
|
||||||
|
(sceneElements.filter(({ id }) =>
|
||||||
|
allBoundElementIds.has(id),
|
||||||
|
) as ExcalidrawLinearElement[]).forEach((element) => {
|
||||||
|
const { startBinding, endBinding } = element;
|
||||||
|
mutateElement(element, {
|
||||||
|
startBinding: newBindingAfterDuplication(
|
||||||
|
startBinding,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
),
|
||||||
|
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the bindable shapes
|
||||||
|
sceneElements
|
||||||
|
.filter(({ id }) => allBindableElementIds.has(id))
|
||||||
|
.forEach((bindableElement) => {
|
||||||
|
const { boundElementIds } = bindableElement;
|
||||||
|
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||||
|
mutateElement(bindableElement, {
|
||||||
|
boundElementIds: boundElementIds.map(
|
||||||
|
(boundElementId) =>
|
||||||
|
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const newBindingAfterDuplication = (
|
||||||
|
binding: PointBinding | null,
|
||||||
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
|
): PointBinding | null => {
|
||||||
|
if (binding == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { elementId, focus, gap } = binding;
|
||||||
|
return {
|
||||||
|
focus,
|
||||||
|
gap,
|
||||||
|
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fixBindingsAfterDeletion = (
|
||||||
|
sceneElements: readonly ExcalidrawElement[],
|
||||||
|
deletedElements: readonly ExcalidrawElement[],
|
||||||
|
): void => {
|
||||||
|
const deletedElementIds = new Set(
|
||||||
|
deletedElements.map((element) => element.id),
|
||||||
|
);
|
||||||
|
// Non deleted and need an update
|
||||||
|
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
deletedElements.forEach((deletedElement) => {
|
||||||
|
if (isBindableElement(deletedElement)) {
|
||||||
|
deletedElement.boundElementIds?.forEach((id) => {
|
||||||
|
if (!deletedElementIds.has(id)) {
|
||||||
|
boundElementIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(sceneElements.filter(({ id }) =>
|
||||||
|
boundElementIds.has(id),
|
||||||
|
) as ExcalidrawLinearElement[]).forEach(
|
||||||
|
(element: ExcalidrawLinearElement) => {
|
||||||
|
const { startBinding, endBinding } = element;
|
||||||
|
mutateElement(element, {
|
||||||
|
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||||
|
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newBindingAfterDeletion = (
|
||||||
|
binding: PointBinding | null,
|
||||||
|
deletedElementIds: Set<ExcalidrawElement["id"]>,
|
||||||
|
): PointBinding | null => {
|
||||||
|
if (binding == null || deletedElementIds.has(binding.elementId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return binding;
|
||||||
|
};
|
@ -10,11 +10,14 @@ import {
|
|||||||
import { isLinearElement } from "./typeChecks";
|
import { 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];
|
||||||
|
@ -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,179 +45,575 @@ 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));
|
||||||
|
};
|
||||||
|
|
||||||
let tx = 0.707;
|
type HitTestArgs = {
|
||||||
let ty = 0.707;
|
element: NonDeletedExcalidrawElement;
|
||||||
|
point: Point;
|
||||||
|
threshold: number;
|
||||||
|
check: (distance: number, threshold: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const a = Math.abs(element.width) / 2;
|
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||||
const b = Math.abs(element.height) / 2;
|
switch (args.element.type) {
|
||||||
|
case "rectangle":
|
||||||
[0, 1, 2, 3].forEach((x) => {
|
case "text":
|
||||||
const xx = a * tx;
|
case "diamond":
|
||||||
const yy = b * ty;
|
case "ellipse":
|
||||||
|
const distance = distanceToBindableElement(args.element, args.point);
|
||||||
const ex = ((a * a - b * b) * tx ** 3) / a;
|
return args.check(distance, args.threshold);
|
||||||
const ey = ((b * b - a * a) * ty ** 3) / b;
|
case "arrow":
|
||||||
|
case "line":
|
||||||
const rx = xx - ex;
|
case "draw":
|
||||||
const ry = yy - ey;
|
return hitTestLinear(args);
|
||||||
|
case "selection":
|
||||||
const qx = px - ex;
|
console.warn(
|
||||||
const qy = py - ey;
|
"This should not happen, we need to investigate why it does.",
|
||||||
|
|
||||||
const r = Math.hypot(ry, rx);
|
|
||||||
const q = Math.hypot(qy, qx);
|
|
||||||
|
|
||||||
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
|
||||||
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
|
||||||
const t = Math.hypot(ty, tx);
|
|
||||||
tx /= t;
|
|
||||||
ty /= t;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isElementDraggableFromInside(element, appState)) {
|
|
||||||
return (
|
|
||||||
a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
|
|
||||||
} else if (element.type === "rectangle") {
|
|
||||||
if (isElementDraggableFromInside(element, appState)) {
|
|
||||||
return (
|
|
||||||
x > x1 - lineThreshold &&
|
|
||||||
x < x2 + lineThreshold &&
|
|
||||||
y > y1 - lineThreshold &&
|
|
||||||
y < y2 + lineThreshold
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (x1, y1) --A-- (x2, y1)
|
|
||||||
// |D |B
|
|
||||||
// (x1, y2) --C-- (x2, y2)
|
|
||||||
return (
|
|
||||||
distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
|
|
||||||
distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
|
|
||||||
distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
|
|
||||||
distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
|
|
||||||
);
|
|
||||||
} else if (element.type === "diamond") {
|
|
||||||
x -= element.x;
|
|
||||||
y -= element.y;
|
|
||||||
let [
|
|
||||||
topX,
|
|
||||||
topY,
|
|
||||||
rightX,
|
|
||||||
rightY,
|
|
||||||
bottomX,
|
|
||||||
bottomY,
|
|
||||||
leftX,
|
|
||||||
leftY,
|
|
||||||
] = getDiamondPoints(element);
|
|
||||||
|
|
||||||
if (isElementDraggableFromInside(element, appState)) {
|
|
||||||
// TODO: remove this when we normalize coordinates globally
|
|
||||||
if (topY > bottomY) {
|
|
||||||
[bottomY, topY] = [topY, bottomY];
|
|
||||||
}
|
|
||||||
if (rightX < leftX) {
|
|
||||||
[leftX, rightX] = [rightX, leftX];
|
|
||||||
}
|
|
||||||
|
|
||||||
topY -= lineThreshold;
|
|
||||||
bottomY += lineThreshold;
|
|
||||||
leftX -= lineThreshold;
|
|
||||||
rightX += lineThreshold;
|
|
||||||
|
|
||||||
// all deltas should be < 0. Delta > 0 indicates it's on the outside side
|
|
||||||
// of the line.
|
|
||||||
//
|
|
||||||
// (topX, topY)
|
|
||||||
// D / \ A
|
|
||||||
// / \
|
|
||||||
// (leftX, leftY) (rightX, rightY)
|
|
||||||
// C \ / B
|
|
||||||
// \ /
|
|
||||||
// (bottomX, bottomY)
|
|
||||||
//
|
|
||||||
// https://stackoverflow.com/a/2752753/927631
|
|
||||||
return (
|
|
||||||
// delta from line D
|
|
||||||
(leftX - topX) * (y - leftY) - (leftX - x) * (topY - leftY) <= 0 &&
|
|
||||||
// delta from line A
|
|
||||||
(topX - rightX) * (y - rightY) - (x - rightX) * (topY - rightY) <= 0 &&
|
|
||||||
// delta from line B
|
|
||||||
(rightX - bottomX) * (y - bottomY) -
|
|
||||||
(x - bottomX) * (rightY - bottomY) <=
|
|
||||||
0 &&
|
|
||||||
// delta from line C
|
|
||||||
(bottomX - leftX) * (y - leftY) - (x - leftX) * (bottomY - leftY) <= 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) <
|
|
||||||
lineThreshold ||
|
|
||||||
distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) <
|
|
||||||
lineThreshold ||
|
|
||||||
distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) <
|
|
||||||
lineThreshold ||
|
|
||||||
distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
|
|
||||||
lineThreshold
|
|
||||||
);
|
|
||||||
} else if (isLinearElement(element)) {
|
|
||||||
if (!getShapeForElement(element)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const shape = getShapeForElement(element) as Drawable[];
|
};
|
||||||
|
|
||||||
if (
|
export const distanceToBindableElement = (
|
||||||
x < x1 - lineThreshold ||
|
element: ExcalidrawBindableElement,
|
||||||
y < y1 - lineThreshold ||
|
point: Point,
|
||||||
x > x2 + lineThreshold ||
|
): number => {
|
||||||
y > y2 + lineThreshold
|
switch (element.type) {
|
||||||
) {
|
case "rectangle":
|
||||||
return false;
|
case "text":
|
||||||
}
|
return distanceToRectangle(element, point);
|
||||||
|
case "diamond":
|
||||||
|
return distanceToDiamond(element, point);
|
||||||
|
case "ellipse":
|
||||||
|
return distanceToEllipse(element, point);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const relX = x - element.x;
|
const isInsideCheck = (distance: number, threshold: number): boolean => {
|
||||||
const relY = y - element.y;
|
return distance < threshold;
|
||||||
|
};
|
||||||
|
|
||||||
if (isElementDraggableFromInside(element, appState)) {
|
const isNearCheck = (distance: number, threshold: number): boolean => {
|
||||||
const hit = shape.some((subshape) =>
|
return Math.abs(distance) < threshold;
|
||||||
hitTestCurveInside(subshape, relX, relY, lineThreshold),
|
};
|
||||||
);
|
|
||||||
if (hit) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hit thest all "subshapes" of the linear element
|
const isOutsideCheck = (distance: number, threshold: number): boolean => {
|
||||||
return shape.some((subshape) =>
|
return 0 <= distance && distance < threshold;
|
||||||
hitTestRoughShape(subshape, relX, relY, lineThreshold),
|
};
|
||||||
);
|
|
||||||
} else if (element.type === "text") {
|
const distanceToRectangle = (
|
||||||
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
|
element: ExcalidrawRectangleElement | ExcalidrawTextElement,
|
||||||
} else if (element.type === "selection") {
|
point: Point,
|
||||||
console.warn("This should not happen, we need to investigate why it does.");
|
): number => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||||
|
const nearSide =
|
||||||
|
GAPoint.distanceToLine(pointRel, GALine.vector(hwidth, hheight)) > 0
|
||||||
|
? GALine.equation(0, 1, -hheight)
|
||||||
|
: GALine.equation(1, 0, -hwidth);
|
||||||
|
return GAPoint.distanceToLine(pointRel, nearSide);
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToDiamond = (
|
||||||
|
element: ExcalidrawDiamondElement,
|
||||||
|
point: Point,
|
||||||
|
): number => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||||
|
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
|
||||||
|
return GAPoint.distanceToLine(pointRel, side);
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceToEllipse = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
point: Point,
|
||||||
|
): number => {
|
||||||
|
const [pointRel, tangent] = ellipseParamsForTest(element, point);
|
||||||
|
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ellipseParamsForTest = (
|
||||||
|
element: ExcalidrawEllipseElement,
|
||||||
|
point: Point,
|
||||||
|
): [GA.Point, GA.Line] => {
|
||||||
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||||
|
const [px, py] = GAPoint.toTuple(pointRel);
|
||||||
|
|
||||||
|
// We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
|
||||||
|
let tx = 0.707;
|
||||||
|
let ty = 0.707;
|
||||||
|
|
||||||
|
const a = hwidth;
|
||||||
|
const b = hheight;
|
||||||
|
|
||||||
|
// This is a numerical method to find the params tx, ty at which
|
||||||
|
// the ellipse has the closest point to the given point
|
||||||
|
[0, 1, 2, 3].forEach((_) => {
|
||||||
|
const xx = a * tx;
|
||||||
|
const yy = b * ty;
|
||||||
|
|
||||||
|
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||||
|
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||||
|
|
||||||
|
const rx = xx - ex;
|
||||||
|
const ry = yy - ey;
|
||||||
|
|
||||||
|
const qx = px - ex;
|
||||||
|
const qy = py - ey;
|
||||||
|
|
||||||
|
const r = Math.hypot(ry, rx);
|
||||||
|
const q = Math.hypot(qy, qx);
|
||||||
|
|
||||||
|
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||||
|
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||||
|
const t = Math.hypot(ty, tx);
|
||||||
|
tx /= t;
|
||||||
|
ty /= t;
|
||||||
|
});
|
||||||
|
|
||||||
|
const closestPoint = GA.point(a * tx, b * ty);
|
||||||
|
|
||||||
|
const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
|
||||||
|
return [pointRel, tangent];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||||
|
const { element, threshold } = args;
|
||||||
|
if (!getShapeForElement(element)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
throw new Error(`Unimplemented type ${element.type}`);
|
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 (
|
||||||
|
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
|
||||||
|
!isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [relX, relY] = GAPoint.toTuple(point);
|
||||||
|
|
||||||
|
const shape = getShapeForElement(element) as Drawable[];
|
||||||
|
|
||||||
|
if (args.check === isInsideCheck) {
|
||||||
|
const hit = shape.some((subshape) =>
|
||||||
|
hitTestCurveInside(subshape, relX, relY, threshold),
|
||||||
|
);
|
||||||
|
if (hit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hit test all "subshapes" of the linear element
|
||||||
|
return shape.some((subshape) =>
|
||||||
|
hitTestRoughShape(subshape, relX, relY, threshold),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns:
|
||||||
|
// 1. the point relative to the elements (x, y) position
|
||||||
|
// 2. the point relative to the element's center with positive (x, y)
|
||||||
|
// 3. half element width
|
||||||
|
// 4. half element height
|
||||||
|
//
|
||||||
|
// Note that for linear elements the (x, y) position is not at the
|
||||||
|
// top right corner of their boundary.
|
||||||
|
//
|
||||||
|
// Rectangles, diamonds and ellipses are symmetrical over axes,
|
||||||
|
// and other elements have a rectangular boundary,
|
||||||
|
// so we only need to perform hit tests for the positive quadrant.
|
||||||
|
const pointRelativeToElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
pointTuple: Point,
|
||||||
|
): [GA.Point, GA.Point, number, number] => {
|
||||||
|
const point = GAPoint.from(pointTuple);
|
||||||
|
const elementCoords = getElementAbsoluteCoords(element);
|
||||||
|
const center = coordsCenter(elementCoords);
|
||||||
|
// GA has angle orientation opposite to `rotate`
|
||||||
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
|
const pointRotated = GATransform.apply(rotate, point);
|
||||||
|
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||||
|
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||||
|
const elementPos = GA.offset(element.x, element.y);
|
||||||
|
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||||
|
const [ax, ay, bx, by] = elementCoords;
|
||||||
|
const halfWidth = (bx - ax) / 2;
|
||||||
|
const halfHeight = (by - ay) / 2;
|
||||||
|
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns point in absolute coordinates
|
||||||
|
export const pointInAbsoluteCoords = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
// Point relative to the element position
|
||||||
|
point: Point,
|
||||||
|
): Point => {
|
||||||
|
const [x, y] = point;
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
const cx = (x2 - x1) / 2;
|
||||||
|
const cy = (y2 - y1) / 2;
|
||||||
|
const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle);
|
||||||
|
return [element.x + rotatedX, element.y + rotatedY];
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativizationToElementCenter = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): GA.Transform => {
|
||||||
|
const elementCoords = getElementAbsoluteCoords(element);
|
||||||
|
const center = coordsCenter(elementCoords);
|
||||||
|
// GA has angle orientation opposite to `rotate`
|
||||||
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
|
const translate = GA.reverse(
|
||||||
|
GATransform.translation(GADirection.from(center)),
|
||||||
|
);
|
||||||
|
return GATransform.compose(rotate, translate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||||
|
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The focus distance is the oriented ratio between the size of
|
||||||
|
// the `element` and the "focus image" of the element on which
|
||||||
|
// all focus points lie, so it's a number between -1 and 1.
|
||||||
|
// The line going through `a` and `b` is a tangent to the "focus image"
|
||||||
|
// of the element.
|
||||||
|
export const determineFocusDistance = (
|
||||||
|
element: ExcalidrawBindableElement,
|
||||||
|
// Point on the line, in absolute coordinates
|
||||||
|
a: Point,
|
||||||
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
|
b: Point,
|
||||||
|
): number => {
|
||||||
|
const relateToCenter = relativizationToElementCenter(element);
|
||||||
|
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
|
||||||
|
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
|
||||||
|
const line = GALine.through(aRel, bRel);
|
||||||
|
const q = element.height / element.width;
|
||||||
|
const hwidth = element.width / 2;
|
||||||
|
const hheight = element.height / 2;
|
||||||
|
const n = line[2];
|
||||||
|
const m = line[3];
|
||||||
|
const c = line[1];
|
||||||
|
const mabs = Math.abs(m);
|
||||||
|
const nabs = Math.abs(n);
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "text":
|
||||||
|
return c / (hwidth * (nabs + q * mabs));
|
||||||
|
case "diamond":
|
||||||
|
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||||
|
case "ellipse":
|
||||||
|
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
@ -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 });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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,22 +124,40 @@ 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)
|
|
||||||
) {
|
) {
|
||||||
LinearElementEditor.movePoint(
|
if (isPathALoop(element.points)) {
|
||||||
element,
|
LinearElementEditor.movePoint(
|
||||||
activePointIndex,
|
element,
|
||||||
activePointIndex === 0
|
activePointIndex,
|
||||||
? element.points[element.points.length - 1]
|
activePointIndex === 0
|
||||||
: element.points[0],
|
? element.points[element.points.length - 1]
|
||||||
);
|
: 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],
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
340
src/ga.ts
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* This is a 2D Projective Geometric Algebra implementation.
|
||||||
|
*
|
||||||
|
* For wider context on geometric algebra visit see https://bivector.net.
|
||||||
|
*
|
||||||
|
* For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf.
|
||||||
|
*
|
||||||
|
* Converted from generator written by enki, with a ton of added on top.
|
||||||
|
*
|
||||||
|
* This library uses 8-vectors to represent points, directions and lines
|
||||||
|
* in 2D space.
|
||||||
|
*
|
||||||
|
* An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector:
|
||||||
|
* a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012
|
||||||
|
*
|
||||||
|
* See GAPoint, GALine, GADirection and GATransform modules for common
|
||||||
|
* operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Point = NVector;
|
||||||
|
export type Direction = NVector;
|
||||||
|
export type Line = NVector;
|
||||||
|
export type Transform = NVector;
|
||||||
|
|
||||||
|
export function point(x: number, y: number): Point {
|
||||||
|
return [0, 0, 0, 0, y, x, 1, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function origin(): Point {
|
||||||
|
return [0, 0, 0, 0, 0, 0, 1, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function direction(x: number, y: number): Direction {
|
||||||
|
const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))`
|
||||||
|
return [0, 0, 0, 0, y / norm, x / norm, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offset(x: number, y: number): Direction {
|
||||||
|
return [0, 0, 0, 0, y, x, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is the "implementation" part of the library
|
||||||
|
|
||||||
|
type NVector = readonly [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
|
||||||
|
// These are labels for what each number in an nvector represents
|
||||||
|
const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"];
|
||||||
|
|
||||||
|
// Used to represent points, lines and transformations
|
||||||
|
export function nvector(value: number = 0, index: number = 0): NVector {
|
||||||
|
const result = [0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
if (index < 0 || index > 7) {
|
||||||
|
throw new Error(`Expected \`index\` betwen 0 and 7, got \`${index}\``);
|
||||||
|
}
|
||||||
|
if (value !== 0) {
|
||||||
|
result[index] = value;
|
||||||
|
}
|
||||||
|
return (result as unknown) as NVector;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING_EPSILON = 0.000001;
|
||||||
|
export function toString(nvector: NVector): string {
|
||||||
|
const result = nvector
|
||||||
|
.map((value, index) =>
|
||||||
|
Math.abs(value) > STRING_EPSILON
|
||||||
|
? value.toFixed(7).replace(/(\.|0+)$/, "") +
|
||||||
|
(index > 0 ? NVECTOR_BASE[index] : "")
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
.filter((representation) => representation != null)
|
||||||
|
.join(" + ");
|
||||||
|
return result === "" ? "0" : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the order of the basis blades.
|
||||||
|
export function reverse(nvector: NVector): NVector {
|
||||||
|
return [
|
||||||
|
nvector[0],
|
||||||
|
nvector[1],
|
||||||
|
nvector[2],
|
||||||
|
nvector[3],
|
||||||
|
-nvector[4],
|
||||||
|
-nvector[5],
|
||||||
|
-nvector[6],
|
||||||
|
-nvector[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poincare duality operator.
|
||||||
|
export function dual(nvector: NVector): NVector {
|
||||||
|
return [
|
||||||
|
nvector[7],
|
||||||
|
nvector[6],
|
||||||
|
nvector[5],
|
||||||
|
nvector[4],
|
||||||
|
nvector[3],
|
||||||
|
nvector[2],
|
||||||
|
nvector[1],
|
||||||
|
nvector[0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clifford Conjugation
|
||||||
|
export function conjugate(nvector: NVector): NVector {
|
||||||
|
return [
|
||||||
|
nvector[0],
|
||||||
|
-nvector[1],
|
||||||
|
-nvector[2],
|
||||||
|
-nvector[3],
|
||||||
|
-nvector[4],
|
||||||
|
-nvector[5],
|
||||||
|
-nvector[6],
|
||||||
|
nvector[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main involution
|
||||||
|
export function involute(nvector: NVector): NVector {
|
||||||
|
return [
|
||||||
|
nvector[0],
|
||||||
|
-nvector[1],
|
||||||
|
-nvector[2],
|
||||||
|
-nvector[3],
|
||||||
|
nvector[4],
|
||||||
|
nvector[5],
|
||||||
|
nvector[6],
|
||||||
|
-nvector[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multivector addition
|
||||||
|
export function add(a: NVector, b: NVector | number): NVector {
|
||||||
|
if (isNumber(b)) {
|
||||||
|
return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
a[0] + b[0],
|
||||||
|
a[1] + b[1],
|
||||||
|
a[2] + b[2],
|
||||||
|
a[3] + b[3],
|
||||||
|
a[4] + b[4],
|
||||||
|
a[5] + b[5],
|
||||||
|
a[6] + b[6],
|
||||||
|
a[7] + b[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multivector subtraction
|
||||||
|
export function sub(a: NVector, b: NVector | number): NVector {
|
||||||
|
if (isNumber(b)) {
|
||||||
|
return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
a[0] - b[0],
|
||||||
|
a[1] - b[1],
|
||||||
|
a[2] - b[2],
|
||||||
|
a[3] - b[3],
|
||||||
|
a[4] - b[4],
|
||||||
|
a[5] - b[5],
|
||||||
|
a[6] - b[6],
|
||||||
|
a[7] - b[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The geometric product.
|
||||||
|
export function mul(a: NVector, b: NVector | number): NVector {
|
||||||
|
if (isNumber(b)) {
|
||||||
|
return [
|
||||||
|
a[0] * b,
|
||||||
|
a[1] * b,
|
||||||
|
a[2] * b,
|
||||||
|
a[3] * b,
|
||||||
|
a[4] * b,
|
||||||
|
a[5] * b,
|
||||||
|
a[6] * b,
|
||||||
|
a[7] * b,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
mulScalar(a, b),
|
||||||
|
b[1] * a[0] +
|
||||||
|
b[0] * a[1] -
|
||||||
|
b[4] * a[2] +
|
||||||
|
b[5] * a[3] +
|
||||||
|
b[2] * a[4] -
|
||||||
|
b[3] * a[5] -
|
||||||
|
b[7] * a[6] -
|
||||||
|
b[6] * a[7],
|
||||||
|
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
|
||||||
|
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
|
||||||
|
b[4] * a[0] +
|
||||||
|
b[2] * a[1] -
|
||||||
|
b[1] * a[2] +
|
||||||
|
b[7] * a[3] +
|
||||||
|
b[0] * a[4] +
|
||||||
|
b[6] * a[5] -
|
||||||
|
b[5] * a[6] +
|
||||||
|
b[3] * a[7],
|
||||||
|
b[5] * a[0] -
|
||||||
|
b[3] * a[1] +
|
||||||
|
b[7] * a[2] +
|
||||||
|
b[1] * a[3] -
|
||||||
|
b[6] * a[4] +
|
||||||
|
b[0] * a[5] +
|
||||||
|
b[4] * a[6] +
|
||||||
|
b[2] * a[7],
|
||||||
|
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
|
||||||
|
b[7] * a[0] +
|
||||||
|
b[6] * a[1] +
|
||||||
|
b[5] * a[2] +
|
||||||
|
b[4] * a[3] +
|
||||||
|
b[3] * a[4] +
|
||||||
|
b[2] * a[5] +
|
||||||
|
b[1] * a[6] +
|
||||||
|
b[0] * a[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mulScalar(a: NVector, b: NVector): number {
|
||||||
|
return b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outer/exterior/wedge product.
|
||||||
|
export function meet(a: NVector, b: NVector): NVector {
|
||||||
|
return [
|
||||||
|
b[0] * a[0],
|
||||||
|
b[1] * a[0] + b[0] * a[1],
|
||||||
|
b[2] * a[0] + b[0] * a[2],
|
||||||
|
b[3] * a[0] + b[0] * a[3],
|
||||||
|
b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4],
|
||||||
|
b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5],
|
||||||
|
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
|
||||||
|
b[7] * a[0] +
|
||||||
|
b[6] * a[1] +
|
||||||
|
b[5] * a[2] +
|
||||||
|
b[4] * a[3] +
|
||||||
|
b[3] * a[4] +
|
||||||
|
b[2] * a[5] +
|
||||||
|
b[1] * a[6],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regressive product.
|
||||||
|
export function join(a: NVector, b: NVector): NVector {
|
||||||
|
return [
|
||||||
|
joinScalar(a, b),
|
||||||
|
a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1],
|
||||||
|
a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2],
|
||||||
|
a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3],
|
||||||
|
a[4] * b[7] + a[7] * b[4],
|
||||||
|
a[5] * b[7] + a[7] * b[5],
|
||||||
|
a[6] * b[7] + a[7] * b[6],
|
||||||
|
a[7] * b[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinScalar(a: NVector, b: NVector): number {
|
||||||
|
return (
|
||||||
|
a[0] * b[7] +
|
||||||
|
a[1] * b[6] +
|
||||||
|
a[2] * b[5] +
|
||||||
|
a[3] * b[4] +
|
||||||
|
a[4] * b[3] +
|
||||||
|
a[5] * b[2] +
|
||||||
|
a[6] * b[1] +
|
||||||
|
a[7] * b[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inner product.
|
||||||
|
export function dot(a: NVector, b: NVector): NVector {
|
||||||
|
return [
|
||||||
|
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6],
|
||||||
|
b[1] * a[0] +
|
||||||
|
b[0] * a[1] -
|
||||||
|
b[4] * a[2] +
|
||||||
|
b[5] * a[3] +
|
||||||
|
b[2] * a[4] -
|
||||||
|
b[3] * a[5] -
|
||||||
|
b[7] * a[6] -
|
||||||
|
b[6] * a[7],
|
||||||
|
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
|
||||||
|
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
|
||||||
|
b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7],
|
||||||
|
b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7],
|
||||||
|
b[6] * a[0] + b[0] * a[6],
|
||||||
|
b[7] * a[0] + b[0] * a[7],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function norm(a: NVector): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inorm(a: NVector): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalized(a: NVector): NVector {
|
||||||
|
const n = norm(a);
|
||||||
|
if (n === 0 || n === 1) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const sign = a[6] < 0 ? -1 : 1;
|
||||||
|
return mul(a, sign / n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inormalized(a: NVector): NVector {
|
||||||
|
const n = inorm(a);
|
||||||
|
if (n === 0 || n === 1) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return mul(a, 1 / n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumber(a: any): a is number {
|
||||||
|
return typeof a === "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const E0: NVector = nvector(1, 1);
|
||||||
|
export const E1: NVector = nvector(1, 2);
|
||||||
|
export const E2: NVector = nvector(1, 3);
|
||||||
|
export const E01: NVector = nvector(1, 4);
|
||||||
|
export const E20: NVector = nvector(1, 5);
|
||||||
|
export const E12: NVector = nvector(1, 6);
|
||||||
|
export const E012: NVector = nvector(1, 7);
|
||||||
|
export const I = E012;
|
23
src/gadirections.ts
Normal file
23
src/gadirections.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as GA from "./ga";
|
||||||
|
import { Line, Direction, Point } from "./ga";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
|
||||||
|
* vector `(x, y)`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function from(point: Point): Point {
|
||||||
|
return [0, 0, 0, 0, point[4], point[5], 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromTo(from: Point, to: Point): Direction {
|
||||||
|
return GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orthogonal(direction: Direction): Direction {
|
||||||
|
return GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orthogonalToLine(line: Line): Direction {
|
||||||
|
return GA.mul(line, GA.I);
|
||||||
|
}
|
62
src/galines.ts
Normal file
62
src/galines.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import * as GA from "./ga";
|
||||||
|
import { Line, Point } from "./ga";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
|
||||||
|
* c * e0 + a * e1 + b*e2
|
||||||
|
*
|
||||||
|
* This maps to a standard formula `a * x + b * y + c`.
|
||||||
|
*
|
||||||
|
* `(-b, a)` correponds to a 2D vector parallel to the line. The lines
|
||||||
|
* have a natural orientation, corresponding to that vector.
|
||||||
|
*
|
||||||
|
* The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
|
||||||
|
* `c / norm(line)` is the oriented distance from line to origin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Returns line with direction (x, y) through origin
|
||||||
|
export function vector(x: number, y: number): Line {
|
||||||
|
return GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For equation ax + by + c = 0.
|
||||||
|
export function equation(a: number, b: number, c: number): Line {
|
||||||
|
return GA.normalized([0, c, a, b, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function through(from: Point, to: Point): Line {
|
||||||
|
return GA.normalized(GA.join(to, from));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orthogonal(line: Line, point: Point): Line {
|
||||||
|
return GA.dot(line, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a line perpendicular to the line through `against` and `intersection`
|
||||||
|
// going through `intersection`.
|
||||||
|
export function orthogonalThrough(against: Point, intersection: Point): Line {
|
||||||
|
return orthogonal(through(against, intersection), intersection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parallel(line: Line, distance: number): Line {
|
||||||
|
const result = line.slice();
|
||||||
|
result[1] -= distance;
|
||||||
|
return (result as unknown) as Line;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parallelThrough(line: Line, point: Point): Line {
|
||||||
|
return orthogonal(orthogonal(point, line), point);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distance(line1: Line, line2: Line): number {
|
||||||
|
return GA.inorm(GA.meet(line1, line2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function angle(line1: Line, line2: Line): number {
|
||||||
|
return Math.acos(GA.dot(line1, line2)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The orientation of the line
|
||||||
|
export function sign(line: Line): number {
|
||||||
|
return Math.sign(line[1]);
|
||||||
|
}
|
37
src/gapoints.ts
Normal file
37
src/gapoints.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as GA from "./ga";
|
||||||
|
import * as GALine from "./galines";
|
||||||
|
import { Point, Line, join } from "./ga";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: docs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function from([x, y]: readonly [number, number]): Point {
|
||||||
|
return [0, 0, 0, 0, y, x, 1, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTuple(point: Point): [number, number] {
|
||||||
|
return [point[5], point[4]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abs(point: Point): Point {
|
||||||
|
return [0, 0, 0, 0, Math.abs(point[4]), Math.abs(point[5]), 1, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intersect(line1: Line, line2: Line): Point {
|
||||||
|
return GA.normalized(GA.meet(line1, line2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects `point` onto the `line`.
|
||||||
|
// The returned point is the closest point on the `line` to the `point`.
|
||||||
|
export function project(point: Point, line: Line): Point {
|
||||||
|
return intersect(GALine.orthogonal(line, point), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distance(point1: Point, point2: Point): number {
|
||||||
|
return GA.norm(join(point1, point2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceToLine(point: Point, line: Line): number {
|
||||||
|
return GA.joinScalar(point, line);
|
||||||
|
}
|
38
src/gatransforms.ts
Normal file
38
src/gatransforms.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import * as GA from "./ga";
|
||||||
|
import { Line, Direction, Point, Transform } from "./ga";
|
||||||
|
import * as GADirection from "./gadirections";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: docs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function rotation(pivot: Point, angle: number): Transform {
|
||||||
|
return GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translation(direction: Direction): Transform {
|
||||||
|
return [1, 0, 0, 0, -(0.5 * direction[5]), 0.5 * direction[4], 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translationOrthogonal(
|
||||||
|
direction: Direction,
|
||||||
|
distance: number,
|
||||||
|
): Transform {
|
||||||
|
const scale = 0.5 * distance;
|
||||||
|
return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translationAlong(line: Line, distance: number): Transform {
|
||||||
|
return GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compose(motor1: Transform, motor2: Transform): Transform {
|
||||||
|
return GA.mul(motor2, motor1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apply(
|
||||||
|
motor: Transform,
|
||||||
|
nvector: Point | Direction | Line,
|
||||||
|
): Point | Direction | Line {
|
||||||
|
return GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));
|
||||||
|
}
|
76
src/math.ts
76
src/math.ts
@ -2,45 +2,6 @@ import { Point } from "./types";
|
|||||||
import { LINE_CONFIRM_THRESHOLD } from "./constants";
|
import { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||||
if (locallySelectedElements.length === 1) {
|
if (locallySelectedElements.length === 1) {
|
||||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
|
||||||
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];
|
context,
|
||||||
if (handler !== undefined) {
|
sceneState,
|
||||||
const lineWidth = context.lineWidth;
|
handlers,
|
||||||
context.lineWidth = 1 / sceneState.zoom;
|
locallySelectedElements[0].angle,
|
||||||
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,
|
|
||||||
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,37 +403,9 @@ 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
|
||||||
@ -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,
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
@ -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,
|
||||||
|
@ -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,
|
||||||
|
70
src/tests/geometricAlgebra.test.ts
Normal file
70
src/tests/geometricAlgebra.test.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import * as GA from "../ga";
|
||||||
|
import { point, toString, direction, offset } from "../ga";
|
||||||
|
import * as GAPoint from "../gapoints";
|
||||||
|
import * as GALine from "../galines";
|
||||||
|
import * as GATransform from "../gatransforms";
|
||||||
|
|
||||||
|
describe("geometric algebra", () => {
|
||||||
|
describe("points", () => {
|
||||||
|
it("distanceToLine", () => {
|
||||||
|
const point = GA.point(3, 3);
|
||||||
|
const line = GALine.equation(0, 1, -1);
|
||||||
|
expect(GAPoint.distanceToLine(point, line)).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distanceToLine neg", () => {
|
||||||
|
const point = GA.point(-3, -3);
|
||||||
|
const line = GALine.equation(0, 1, -1);
|
||||||
|
expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("lines", () => {
|
||||||
|
it("through", () => {
|
||||||
|
const a = GA.point(0, 0);
|
||||||
|
const b = GA.point(2, 0);
|
||||||
|
expect(toString(GALine.through(a, b))).toEqual(
|
||||||
|
toString(GALine.equation(0, 2, 0)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("parallel", () => {
|
||||||
|
const point = GA.point(3, 3);
|
||||||
|
const line = GALine.equation(0, 1, -1);
|
||||||
|
const parallel = GALine.parallel(line, 2);
|
||||||
|
expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("translation", () => {
|
||||||
|
it("points", () => {
|
||||||
|
const start = point(2, 2);
|
||||||
|
const move = GATransform.translation(direction(0, 1));
|
||||||
|
const end = GATransform.apply(move, start);
|
||||||
|
expect(toString(end)).toEqual(toString(point(2, 3)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points 2", () => {
|
||||||
|
const start = point(2, 2);
|
||||||
|
const move = GATransform.translation(offset(3, 4));
|
||||||
|
const end = GATransform.apply(move, start);
|
||||||
|
expect(toString(end)).toEqual(toString(point(5, 6)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lines", () => {
|
||||||
|
const original = GALine.through(point(2, 2), point(3, 4));
|
||||||
|
const move = GATransform.translation(offset(3, 4));
|
||||||
|
const parallel = GATransform.apply(move, original);
|
||||||
|
expect(toString(parallel)).toEqual(
|
||||||
|
toString(GALine.through(point(5, 6), point(6, 8))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("rotation", () => {
|
||||||
|
it("points", () => {
|
||||||
|
const start = point(2, 2);
|
||||||
|
const pivot = point(1, 1);
|
||||||
|
const rotate = GATransform.rotation(pivot, Math.PI / 2);
|
||||||
|
const end = GATransform.apply(rotate, start);
|
||||||
|
expect(toString(end)).toEqual(toString(point(2, 0)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -83,7 +83,9 @@ describe("duplicate element on move when ALT is clicked", () => {
|
|||||||
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
|
fireEvent.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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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 };
|
||||||
|
Loading…
x
Reference in New Issue
Block a user