diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 547b33bc..a94b8aa5 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -11,6 +11,7 @@ import { AppState } from "../types"; import { newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; import { LinearElementEditor } from "../element/linearElementEditor"; +import { fixBindingsAfterDeletion } from "../element/binding"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], @@ -53,7 +54,12 @@ export const actionDeleteSelected = register({ name: "deleteSelectedElements", perform: (elements, appState) => { if (appState.editingLinearElement) { - const { elementId, activePointIndex } = appState.editingLinearElement; + const { + elementId, + activePointIndex, + startBindingElement, + endBindingElement, + } = appState.editingLinearElement; const element = LinearElementEditor.getElement(elementId); if (!element) { return false; @@ -62,7 +68,7 @@ export const actionDeleteSelected = register({ // case: no point selected → delete whole element activePointIndex == null || activePointIndex === -1 || - // case: deleting last point + // case: deleting last remaining point element.points.length < 2 ) { const nextElements = elements.filter((el) => el.id !== element.id); @@ -78,6 +84,17 @@ export const actionDeleteSelected = register({ }; } + // We cannot do this inside `movePoint` because it is also called + // when deleting the uncommitted point (which hasn't caused any binding) + const binding = { + startBindingElement: + activePointIndex === 0 ? null : startBindingElement, + endBindingElement: + activePointIndex === element.points.length - 1 + ? null + : endBindingElement, + }; + LinearElementEditor.movePoint(element, activePointIndex, "delete"); return { @@ -86,6 +103,7 @@ export const actionDeleteSelected = register({ ...appState, editingLinearElement: { ...appState.editingLinearElement, + ...binding, activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0, }, }, @@ -97,6 +115,10 @@ export const actionDeleteSelected = register({ elements: nextElements, appState: nextAppState, } = deleteSelectedElements(elements, appState); + fixBindingsAfterDeletion( + nextElements, + elements.filter(({ id }) => appState.selectedElementIds[id]), + ); nextAppState = handleGroupEditingState(nextAppState, nextElements); diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index 8c4f0dbd..718bdbb7 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -11,6 +11,9 @@ import { getShortcutKey } from "../utils"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement } from "../element/mutateElement"; import { selectGroupsForSelectedElements } from "../groups"; +import { AppState } from "../types"; +import { fixBindingsAfterDuplication } from "../element/binding"; +import { ActionResult } from "./types"; export const actionDuplicateSelection = register({ name: "duplicateSelection", @@ -50,40 +53,8 @@ export const actionDuplicateSelection = register({ }; } - const groupIdMap = new Map(); - const newElements: ExcalidrawElement[] = []; - const finalElements = elements.reduce( - (acc: Array, element: ExcalidrawElement) => { - if (appState.selectedElementIds[element.id]) { - const newElement = duplicateElement( - appState.editingGroupId, - groupIdMap, - element, - { - x: element.x + 10, - y: element.y + 10, - }, - ); - newElements.push(newElement); - return acc.concat([element, newElement]); - } - return acc.concat(element); - }, - [], - ); return { - appState: selectGroupsForSelectedElements( - { - ...appState, - selectedGroupIds: {}, - selectedElementIds: newElements.reduce((acc, element) => { - acc[element.id] = true; - return acc; - }, {} as any), - }, - getNonDeletedElements(finalElements), - ), - elements: finalElements, + ...duplicateElements(elements, appState), commitToHistory: true, }; }, @@ -102,3 +73,49 @@ export const actionDuplicateSelection = register({ /> ), }); + +const duplicateElements = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +): Partial => { + const groupIdMap = new Map(); + const newElements: ExcalidrawElement[] = []; + const oldElements: ExcalidrawElement[] = []; + const oldIdToDuplicatedId = new Map(); + const finalElements = elements.reduce( + (acc: Array, 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), + ), + }; +}; diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 25c07dd2..7c70e10e 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -9,15 +9,32 @@ import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; +import Scene from "../scene/Scene"; +import { + maybeBindLinearElement, + bindOrUnbindLinearElement, +} from "../element/binding"; +import { isBindingElement } from "../element/typeChecks"; export const actionFinalize = register({ name: "finalize", perform: (elements, appState) => { if (appState.editingLinearElement) { - const { elementId } = appState.editingLinearElement; + const { + elementId, + startBindingElement, + endBindingElement, + } = appState.editingLinearElement; const element = LinearElementEditor.getElement(elementId); if (element) { + if (isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + ); + } return { elements: element.points.length < 2 || isInvisiblySmallElement(element) @@ -66,11 +83,12 @@ export const actionFinalize = register({ // If the multi point line closes the loop, // set the last point to first point. // This ensures that loop remains closed at different scales. + const isLoop = isPathALoop(multiPointElement.points); if ( multiPointElement.type === "line" || multiPointElement.type === "draw" ) { - if (isPathALoop(multiPointElement.points)) { + if (isLoop) { const linePoints = multiPointElement.points; const firstPoint = linePoints[0]; mutateElement(multiPointElement, { @@ -83,6 +101,23 @@ export const actionFinalize = register({ } } + if ( + isBindingElement(multiPointElement) && + !isLoop && + multiPointElement.points.length > 1 + ) { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + multiPointElement, + -1, + ); + maybeBindLinearElement( + multiPointElement, + appState, + Scene.getScene(multiPointElement)!, + { x, y }, + ); + } + if (!appState.elementLocked) { appState.selectedElementIds[multiPointElement.id] = true; } @@ -101,6 +136,8 @@ export const actionFinalize = register({ draggingElement: null, multiElement: null, editingElement: null, + startBoundElement: null, + suggestedBindings: [], selectedElementIds: multiPointElement && !appState.elementLocked ? { diff --git a/src/actions/actionHistory.tsx b/src/actions/actionHistory.tsx index 45278ef4..9e3f6232 100644 --- a/src/actions/actionHistory.tsx +++ b/src/actions/actionHistory.tsx @@ -9,6 +9,7 @@ import { AppState } from "../types"; import { KEYS } from "../keys"; import { getElementMap } from "../element"; import { newElementWith } from "../element/mutateElement"; +import { fixBindingsAfterDeletion } from "../element/binding"; const writeData = ( prevElements: readonly ExcalidrawElement[], @@ -31,6 +32,9 @@ const writeData = ( const nextElements = data.elements; const nextElementMap = getElementMap(nextElements); + const deletedElements = prevElements.filter( + (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id), + ); const elements = nextElements .map((nextElement) => newElementWith( @@ -39,14 +43,11 @@ const writeData = ( ), ) .concat( - prevElements - .filter( - (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id), - ) - .map((prevElement) => - newElementWith(prevElement, { isDeleted: true }), - ), + deletedElements.map((prevElement) => + newElementWith(prevElement, { isDeleted: true }), + ), ); + fixBindingsAfterDeletion(elements, deletedElements); return { elements, diff --git a/src/appState.ts b/src/appState.ts index 4bd24cec..9b2e1b4c 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit< resizingElement: null, multiElement: null, editingElement: null, + startBoundElement: null, editingLinearElement: null, elementType: "selection", elementLocked: false, @@ -43,6 +44,7 @@ export const getDefaultAppState = (): Omit< scrolledOutside: false, name: `${t("labels.untitled")}-${getDateTime()}`, username: "", + isBindingEnabled: true, isCollaborating: false, isResizing: false, isRotating: false, @@ -55,6 +57,7 @@ export const getDefaultAppState = (): Omit< collaborators: new Map(), shouldCacheIgnoreZoom: false, showShortcutsDialog: false, + suggestedBindings: [], zenModeEnabled: false, gridSize: null, editingGroupId: null, @@ -96,6 +99,7 @@ const APP_STATE_STORAGE_CONF = (< cursorY: { browser: true, export: false }, draggingElement: { browser: false, export: false }, editingElement: { browser: false, export: false }, + startBoundElement: { browser: false, export: false }, editingGroupId: { browser: true, export: false }, editingLinearElement: { browser: false, export: false }, elementLocked: { browser: true, export: false }, @@ -104,6 +108,7 @@ const APP_STATE_STORAGE_CONF = (< exportBackground: { browser: true, export: false }, gridSize: { browser: true, export: true }, height: { browser: false, export: false }, + isBindingEnabled: { browser: false, export: false }, isCollaborating: { browser: false, export: false }, isLibraryOpen: { browser: false, export: false }, isLoading: { browser: false, export: false }, @@ -124,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (< shouldAddWatermark: { browser: true, export: false }, shouldCacheIgnoreZoom: { browser: true, export: false }, showShortcutsDialog: { browser: false, export: false }, + suggestedBindings: { browser: false, export: false }, username: { browser: true, export: false }, viewBackgroundColor: { browser: true, export: true }, width: { browser: false, export: false }, diff --git a/src/components/App.tsx b/src/components/App.tsx index a07adcbe..c7786afa 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -31,6 +31,7 @@ import { dragSelectedElements, getDragOffsetXY, dragNewElement, + hitTest, } from "../element"; import { getElementsWithinSelection, @@ -60,6 +61,8 @@ import { ExcalidrawTextElement, NonDeleted, ExcalidrawGenericElement, + ExcalidrawLinearElement, + ExcalidrawBindableElement, } from "../element/types"; import { distance2d, isPathALoop, getGridPoint } from "../math"; @@ -136,7 +139,13 @@ import { generateCollaborationLink, getCollaborationLinkData } from "../data"; import { mutateElement, newElementWith } from "../element/mutateElement"; import { invalidateShapeForElement } from "../renderer/renderElement"; import { unstable_batchedUpdates } from "react-dom"; -import { isLinearElement } from "../element/typeChecks"; +import { + isLinearElement, + isLinearElementType, + isBindingElement, + isBindingElementType, + isBindableElement, +} from "../element/typeChecks"; import { actionFinalize, actionDeleteSelected } from "../actions"; import { restoreUsernameFromLocalStorage, @@ -154,6 +163,19 @@ import { } from "../groups"; import { Library } from "../data/library"; import Scene from "../scene/Scene"; +import { + getHoveredElementForBinding, + maybeBindLinearElement, + getEligibleElementsForBinding, + bindOrUnbindSelectedElements, + unbindLinearElements, + fixBindingsAfterDuplication, + maybeBindBindableElement, + getElligibleElementForBindingElementAtCoors, + fixBindingsAfterDeletion, + isLinearElementSimpleAndAlreadyBound, + isBindingEnabled, +} from "../element/binding"; /** * @param func handler taking at most single parameter (event). @@ -407,6 +429,7 @@ class App extends React.Component { private onBlur = withBatchedUpdates(() => { isHoldingSpace = false; + this.setState({ isBindingEnabled: true }); this.saveDebounced(); this.saveDebounced.flush(); }); @@ -690,7 +713,7 @@ class App extends React.Component { this.broadcastScene(SCENE.UPDATE, /* syncAll */ true); }, SYNC_FULL_SCENE_INTERVAL_MS); - componentDidUpdate(prevProps: ExcalidrawProps) { + componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) { const { width: prevWidth, height: prevHeight } = prevProps; const { width: currentWidth, height: currentHeight } = this.props; if (prevWidth !== currentWidth || prevHeight !== currentHeight) { @@ -714,6 +737,25 @@ class App extends React.Component { this.actionManager.executeAction(actionFinalize); }); } + const { multiElement } = prevState; + if ( + prevState.elementType !== this.state.elementType && + multiElement != null && + isBindingEnabled(this.state) && + isBindingElement(multiElement) + ) { + maybeBindLinearElement( + multiElement, + this.state, + this.scene, + tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates( + multiElement, + -1, + ), + ), + ); + } const cursorButton: { [id: string]: string | undefined; @@ -950,16 +992,31 @@ class App extends React.Component { const dy = y - elementsCenterY; const groupIdMap = new Map(); + const oldIdToDuplicatedId = new Map(); const newElements = clipboardElements.map((element) => { - return duplicateElement(this.state.editingGroupId, groupIdMap, element, { - x: element.x + dx - minX, - y: element.y + dy - minY, - }); + const newElement = duplicateElement( + this.state.editingGroupId, + groupIdMap, + element, + { + x: element.x + dx - minX, + y: element.y + dy - minY, + }, + ); + oldIdToDuplicatedId.set(element.id, newElement.id); + return newElement; }); - this.scene.replaceAllElements([ + const nextElements = [ ...this.scene.getElementsIncludingDeleted(), ...newElements, - ]); + ]; + fixBindingsAfterDuplication( + nextElements, + clipboardElements, + oldIdToDuplicatedId, + ); + + this.scene.replaceAllElements(nextElements); history.resumeRecording(); this.setState( selectGroupsForSelectedElements( @@ -1403,6 +1460,9 @@ class App extends React.Component { if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) { this.toggleGridMode(); } + if (event[KEYS.CTRL_OR_CMD]) { + this.setState({ isBindingEnabled: false }); + } if (event.code === "KeyC" && event.altKey && event.shiftKey) { this.copyToClipboardAsPng(); @@ -1511,6 +1571,9 @@ class App extends React.Component { } isHoldingSpace = false; } + if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { + this.setState({ isBindingEnabled: true }); + } }); private selectShapeTool(elementType: AppState["elementType"]) { @@ -1520,6 +1583,9 @@ class App extends React.Component { if (isToolIcon(document.activeElement)) { document.activeElement.blur(); } + if (!isLinearElementType(elementType)) { + this.setState({ suggestedBindings: [] }); + } if (elementType !== "selection") { this.setState({ elementType, @@ -1558,10 +1624,6 @@ class App extends React.Component { gesture.initialScale = null; }); - private setElements = (elements: readonly ExcalidrawElement[]) => { - this.scene.replaceAllElements(elements); - }; - private handleTextWysiwyg( element: ExcalidrawTextElement, { @@ -1612,6 +1674,8 @@ class App extends React.Component { [element.id]: true, }, })); + } else { + fixBindingsAfterDeletion(this.scene.getElements(), [element]); } if (!isDeleted || isExistingElement) { history.resumeRecording(); @@ -1643,13 +1707,7 @@ class App extends React.Component { x: number, y: number, ): NonDeleted | null { - const element = getElementAtPosition( - this.scene.getElements(), - this.state, - x, - y, - this.state.zoom, - ); + const element = this.getElementAtPosition(x, y); if (element && isTextElement(element) && !element.isDeleted) { return element; @@ -1657,6 +1715,15 @@ class App extends React.Component { return null; } + private getElementAtPosition( + x: number, + y: number, + ): NonDeleted | null { + return getElementAtPosition(this.scene.getElements(), (element) => + hitTest(element, this.state, x, y), + ); + } + private startTextEditing = ({ sceneX, sceneY, @@ -1786,14 +1853,7 @@ class App extends React.Component { const selectedGroupIds = getSelectedGroupIds(this.state); if (selectedGroupIds.length > 0) { - const elements = this.scene.getElements(); - const hitElement = getElementAtPosition( - elements, - this.state, - sceneX, - sceneY, - this.state.zoom, - ); + const hitElement = this.getElementAtPosition(sceneX, sceneY); const selectedGroupId = hitElement && @@ -1873,12 +1933,13 @@ class App extends React.Component { } } - const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords( + const scenePointer = viewportCoordsToSceneCoords( event, this.state, this.canvas, window.devicePixelRatio, ); + const { x: scenePointerX, y: scenePointerY } = scenePointer; if ( this.state.editingLinearElement && @@ -1894,6 +1955,27 @@ class App extends React.Component { if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ editingLinearElement }); } + if (editingLinearElement.lastUncommittedPoint != null) { + this.maybeSuggestBindingAtCursor(scenePointer); + } else { + this.setState({ suggestedBindings: [] }); + } + } + + if (isBindingElementType(this.state.elementType)) { + // Hovering with a selected tool or creating new linear element via click + // and point + const { draggingElement } = this.state; + if (isBindingElement(draggingElement)) { + this.maybeSuggestBindingForLinearElementAtCursor( + draggingElement, + "end", + scenePointer, + this.state.startBoundElement, + ); + } else { + this.maybeSuggestBindingAtCursor(scenePointer); + } } if (this.state.multiElement) { @@ -1954,6 +2036,7 @@ class App extends React.Component { }); } } + return; } @@ -2003,13 +2086,7 @@ class App extends React.Component { return; } } - const hitElement = getElementAtPosition( - elements, - this.state, - scenePointerX, - scenePointerY, - this.state.zoom, - ); + const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY); if (this.state.elementType === "text") { document.documentElement.style.cursor = isTextElement(hitElement) ? CURSOR_TYPE.TEXT @@ -2328,24 +2405,7 @@ class App extends React.Component { return; } - if (pointerDownState.scrollbars.isOverHorizontal) { - const x = event.clientX; - const dx = x - pointerDownState.lastCoords.x; - this.setState({ - scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), - }); - pointerDownState.lastCoords.x = x; - return; - } - - if (pointerDownState.scrollbars.isOverVertical) { - const y = event.clientY; - const dy = y - pointerDownState.lastCoords.y; - this.setState({ - scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), - }); - pointerDownState.lastCoords.y = y; - } + this.handlePointerMoveOverScrollbars(event, pointerDownState); }); const onPointerUp = withBatchedUpdates(() => { @@ -2440,8 +2500,7 @@ class App extends React.Component { this.state, (appState) => this.setState(appState), history, - pointerDownState.origin.x, - pointerDownState.origin.y, + pointerDownState.origin, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -2454,12 +2513,9 @@ class App extends React.Component { // hitElement may already be set above, so check first pointerDownState.hit.element = pointerDownState.hit.element ?? - getElementAtPosition( - elements, - this.state, + this.getElementAtPosition( pointerDownState.origin.x, pointerDownState.origin.y, - this.state.zoom, ); this.maybeClearSelectionWhenHittingElement( @@ -2544,7 +2600,7 @@ class App extends React.Component { private handleLinearElementOnPointerDown = ( event: React.PointerEvent, - elementType: "draw" | "line" | "arrow", + elementType: ExcalidrawLinearElement["type"], pointerDownState: PointerDownState, ): void => { if (this.state.multiElement) { @@ -2616,6 +2672,10 @@ class App extends React.Component { mutateElement(element, { points: [...element.points, [0, 0]], }); + const boundElement = getHoveredElementForBinding( + pointerDownState.origin, + this.scene, + ); this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted(), element, @@ -2623,6 +2683,8 @@ class App extends React.Component { this.setState({ draggingElement: element, editingElement: element, + startBoundElement: boundElement, + suggestedBindings: [], }); } }; @@ -2690,33 +2752,21 @@ class App extends React.Component { return; } - if (pointerDownState.scrollbars.isOverHorizontal) { - const x = event.clientX; - const dx = x - pointerDownState.lastCoords.x; - this.setState({ - scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), - }); - pointerDownState.lastCoords.x = x; + if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) { return; } - if (pointerDownState.scrollbars.isOverVertical) { - const y = event.clientY; - const dy = y - pointerDownState.lastCoords.y; - this.setState({ - scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), - }); - pointerDownState.lastCoords.y = y; - return; - } - - const { x, y } = viewportCoordsToSceneCoords( + const pointerCoords = viewportCoordsToSceneCoords( event, this.state, this.canvas, window.devicePixelRatio, ); - const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize); + const [gridX, gridY] = getGridPoint( + pointerCoords.x, + pointerCoords.y, + this.state.gridSize, + ); // for arrows/lines, don't start dragging until a given threshold // to ensure we don't create a 2-point arrow by mistake when @@ -2729,8 +2779,8 @@ class App extends React.Component { ) { if ( distance2d( - x, - y, + pointerCoords.x, + pointerCoords.y, pointerDownState.origin.x, pointerDownState.origin.y, ) < DRAGGING_THRESHOLD @@ -2753,8 +2803,8 @@ class App extends React.Component { isRotating: resizeHandle === "rotation", }); const [resizeX, resizeY] = getGridPoint( - x - pointerDownState.resize.offset.x, - y - pointerDownState.resize.offset.y, + pointerCoords.x - pointerDownState.resize.offset.x, + pointerCoords.y - pointerDownState.resize.offset.y, this.state.gridSize, ); if ( @@ -2775,6 +2825,7 @@ class App extends React.Component { pointerDownState.resize.originalElements, ) ) { + this.maybeSuggestBindingForAll(selectedElements); return; } } @@ -2783,13 +2834,20 @@ class App extends React.Component { const didDrag = LinearElementEditor.handlePointDragging( this.state, (appState) => this.setState(appState), - x, - y, + pointerCoords.x, + pointerCoords.y, + (element, startOrEnd) => { + this.maybeSuggestBindingForLinearElementAtCursor( + element, + startOrEnd, + pointerCoords, + ); + }, ); if (didDrag) { - pointerDownState.lastCoords.x = x; - pointerDownState.lastCoords.y = y; + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; return; } } @@ -2805,11 +2863,12 @@ class App extends React.Component { ); if (selectedElements.length > 0) { const [dragX, dragY] = getGridPoint( - x - pointerDownState.drag.offset.x, - y - pointerDownState.drag.offset.y, + pointerCoords.x - pointerDownState.drag.offset.x, + pointerCoords.y - pointerDownState.drag.offset.y, this.state.gridSize, ); - dragSelectedElements(selectedElements, dragX, dragY); + dragSelectedElements(selectedElements, dragX, dragY, this.scene); + this.maybeSuggestBindingForAll(selectedElements); // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { @@ -2822,6 +2881,7 @@ class App extends React.Component { const nextElements = []; const elementsToAppend = []; const groupIdMap = new Map(); + const oldIdToDuplicatedId = new Map(); for (const element of this.scene.getElementsIncludingDeleted()) { if ( this.state.selectedElementIds[element.id] || @@ -2846,14 +2906,19 @@ class App extends React.Component { }); nextElements.push(duplicatedElement); elementsToAppend.push(element); + oldIdToDuplicatedId.set(element.id, duplicatedElement.id); } else { nextElements.push(element); } } - this.scene.replaceAllElements([ - ...nextElements, - ...elementsToAppend, - ]); + const nextSceneElements = [...nextElements, ...elementsToAppend]; + fixBindingsAfterDuplication( + nextSceneElements, + elementsToAppend, + oldIdToDuplicatedId, + "duplicatesServeAsOld", + ); + this.scene.replaceAllElements(nextSceneElements); } return; } @@ -2872,8 +2937,8 @@ class App extends React.Component { let dx: number; let dy: number; if (draggingElement.type === "draw") { - dx = x - draggingElement.x; - dy = y - draggingElement.y; + dx = pointerCoords.x - draggingElement.x; + dy = pointerCoords.y - draggingElement.y; } else { dx = gridX - draggingElement.x; dy = gridY - draggingElement.y; @@ -2903,16 +2968,25 @@ class App extends React.Component { }); } } + if (isBindingElement(draggingElement)) { + // When creating a linear element by dragging + this.maybeSuggestBindingForLinearElementAtCursor( + draggingElement, + "end", + pointerCoords, + this.state.startBoundElement, + ); + } } else if (draggingElement.type === "selection") { dragNewElement( draggingElement, this.state.elementType, pointerDownState.origin.x, pointerDownState.origin.y, - x, - y, - distance(pointerDownState.origin.x, x), - distance(pointerDownState.origin.y, y), + pointerCoords.x, + pointerCoords.y, + distance(pointerDownState.origin.x, pointerCoords.x), + distance(pointerDownState.origin.y, pointerCoords.y), getResizeWithSidesSameLengthKey(event), getResizeCenterPointKey(event), ); @@ -2929,6 +3003,7 @@ class App extends React.Component { getResizeWithSidesSameLengthKey(event), getResizeCenterPointKey(event), ); + this.maybeSuggestBindingForAll([draggingElement]); } if (this.state.elementType === "selection") { @@ -2963,6 +3038,33 @@ class App extends React.Component { }); } + // Returns whether the pointer move happened over either scrollbar + private handlePointerMoveOverScrollbars( + event: PointerEvent, + pointerDownState: PointerDownState, + ): boolean { + if (pointerDownState.scrollbars.isOverHorizontal) { + const x = event.clientX; + const dx = x - pointerDownState.lastCoords.x; + this.setState({ + scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), + }); + pointerDownState.lastCoords.x = x; + return true; + } + + if (pointerDownState.scrollbars.isOverVertical) { + const y = event.clientY; + const dy = y - pointerDownState.lastCoords.y; + this.setState({ + scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), + }); + pointerDownState.lastCoords.y = y; + return true; + } + return false; + } + private onPointerUpFromPointerDownHandler( pointerDownState: PointerDownState, ): (event: PointerEvent) => void { @@ -2973,6 +3075,7 @@ class App extends React.Component { multiElement, elementType, elementLocked, + isResizing, } = this.state; this.setState({ @@ -2991,14 +3094,19 @@ class App extends React.Component { this.savePointer(childEvent.clientX, childEvent.clientY, "up"); - // if moving start/end point towards start/end point within threshold, - // close the loop + // Handle end of dragging a point of a linear element, might close a loop + // and sets binding element if (this.state.editingLinearElement) { const editingLinearElement = LinearElementEditor.handlePointerUp( + childEvent, this.state.editingLinearElement, + this.state, ); if (editingLinearElement !== this.state.editingLinearElement) { - this.setState({ editingLinearElement }); + this.setState({ + editingLinearElement, + suggestedBindings: [], + }); } } @@ -3021,21 +3129,24 @@ class App extends React.Component { if (draggingElement!.points.length > 1) { history.resumeRecording(); } + const pointerCoords = viewportCoordsToSceneCoords( + childEvent, + this.state, + this.canvas, + window.devicePixelRatio, + ); if ( !pointerDownState.drag.hasOccurred && draggingElement && !multiElement ) { - const { x, y } = viewportCoordsToSceneCoords( - childEvent, - this.state, - this.canvas, - window.devicePixelRatio, - ); mutateElement(draggingElement, { points: [ ...draggingElement.points, - [x - draggingElement.x, y - draggingElement.y], + [ + pointerCoords.x - draggingElement.x, + pointerCoords.y - draggingElement.y, + ], ], }); this.setState({ @@ -3043,6 +3154,18 @@ class App extends React.Component { editingElement: this.state.draggingElement, }); } else if (pointerDownState.drag.hasOccurred && !multiElement) { + if ( + isBindingEnabled(this.state) && + isBindingElement(draggingElement) + ) { + maybeBindLinearElement( + draggingElement, + this.state, + this.scene, + pointerCoords, + ); + } + this.setState({ suggestedBindings: [], startBoundElement: null }); if (!elementLocked) { resetCursor(); this.setState((prevState) => ({ @@ -3086,6 +3209,13 @@ class App extends React.Component { draggingElement, getNormalizedDimensions(draggingElement), ); + + if ( + isBindingEnabled(this.state) && + isBindableElement(draggingElement) + ) { + maybeBindBindableElement(draggingElement); + } } if (resizingElement) { @@ -3155,20 +3285,80 @@ class App extends React.Component { history.resumeRecording(); } + if (pointerDownState.drag.hasOccurred || isResizing) { + (isBindingEnabled(this.state) + ? bindOrUnbindSelectedElements + : unbindLinearElements)( + getSelectedElements(this.scene.getElements(), this.state), + ); + } + if (!elementLocked) { resetCursor(); this.setState({ draggingElement: null, + suggestedBindings: [], elementType: "selection", }); } else { this.setState({ draggingElement: null, + suggestedBindings: [], }); } }); } + private maybeSuggestBindingAtCursor = (pointerCoords: { + x: number; + y: number; + }): void => { + const hoveredBindableElement = getHoveredElementForBinding( + pointerCoords, + this.scene, + ); + this.setState({ + suggestedBindings: + hoveredBindableElement != null ? [hoveredBindableElement] : [], + }); + }; + + private maybeSuggestBindingForLinearElementAtCursor = ( + linearElement: NonDeleted, + 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[], + ): void { + const suggestedBindings = getEligibleElementsForBinding(selectedElements); + this.setState({ suggestedBindings }); + } + private maybeClearSelectionWhenHittingElement( event: React.PointerEvent, hitElement: ExcalidrawElement | null, @@ -3291,13 +3481,7 @@ class App extends React.Component { ); const elements = this.scene.getElements(); - const element = getElementAtPosition( - elements, - this.state, - x, - y, - this.state.zoom, - ); + const element = this.getElementAtPosition(x, y); if (!element) { ContextMenu.push({ options: [ diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index e56cc5d1..9f4843dd 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -590,7 +590,13 @@ const LayerUI = ({ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { const getNecessaryObj = (appState: AppState): Partial => { - const { cursorX, cursorY, ...ret } = appState; + const { + cursorX, + cursorY, + suggestedBindings, + startBoundElement: boundElement, + ...ret + } = appState; return ret; }; const prevAppState = getNecessaryObj(prev.appState); diff --git a/src/data/restore.ts b/src/data/restore.ts index ee9e2117..efc66a79 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -48,7 +48,8 @@ function migrateElementWithProperties( width: element.width || 0, height: element.height || 0, seed: element.seed ?? 1, - groupIds: element.groupIds || [], + groupIds: element.groupIds ?? [], + boundElementIds: element.boundElementIds ?? [], }; return { @@ -85,6 +86,8 @@ const migrateElement = ( case "line": case "arrow": { return migrateElementWithProperties(element, { + startBinding: element.startBinding, + endBinding: element.endBinding, points: // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 @@ -98,7 +101,9 @@ const migrateElement = ( } // generic elements case "ellipse": + return migrateElementWithProperties(element, {}); case "rectangle": + return migrateElementWithProperties(element, {}); case "diamond": return migrateElementWithProperties(element, {}); diff --git a/src/element/binding.ts b/src/element/binding.ts new file mode 100644 index 00000000..a38cac43 --- /dev/null +++ b/src/element/binding.ts @@ -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 + | SuggestedPointBinding; + +export type SuggestedPointBinding = [ + NonDeleted, + "start" | "end" | "both", + NonDeleted, +]; + +export const isBindingEnabled = (appState: AppState): boolean => { + return appState.isBindingEnabled; +}; + +export const bindOrUnbindLinearElement = ( + linearElement: NonDeleted, + startBindingElement: ExcalidrawBindableElement | null | "keep", + endBindingElement: ExcalidrawBindableElement | null | "keep", +): void => { + const boundToElementIds: Set = new Set(); + const unboundFromElementIds: Set = 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, + bindableElement: ExcalidrawBindableElement | null | "keep", + startOrEnd: "start" | "end", + // Is mutated + boundToElementIds: Set, + // Is mutated + unboundFromElementIds: Set, +): 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[], +): 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, +): void => { + getElligibleElementsForBindableElementAndWhere( + bindableElement, + ).forEach(([linearElement, where]) => + bindOrUnbindLinearElement( + linearElement, + where === "end" ? "keep" : bindableElement, + where === "start" ? "keep" : bindableElement, + ), + ); +}; + +export const maybeBindLinearElement = ( + linearElement: NonDeleted, + 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, + 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, + bindableElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", +): boolean => { + const otherBinding = + linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; + return isLinearElementSimpleAndAlreadyBound( + linearElement, + otherBinding?.elementId, + bindableElement, + ); +}; + +export const isLinearElementSimpleAndAlreadyBound = ( + linearElement: NonDeleted, + alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, + bindableElement: ExcalidrawBindableElement, +): boolean => { + return ( + alreadyBoundToId === bindableElement.id && linearElement.points.length < 3 + ); +}; + +export const unbindLinearElements = ( + elements: NonDeleted[], +): void => { + elements.forEach((element) => { + if (isBindingElement(element)) { + bindOrUnbindLinearElement(element, null, null); + } + }); +}; + +const unbindLinearElement = ( + linearElement: NonDeleted, + 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 | null => { + const hoveredElement = getElementAtPosition( + scene.getElements(), + (element) => + isBindableElement(element) && bindingBorderTest(element, pointerCoords), + ); + return hoveredElement as NonDeleted | null; +}; + +const calculateFocusAndGap = ( + linearElement: NonDeleted, + 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[]).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, + changedElement: ExcalidrawBindableElement, +) => { + return ( + boundElement.startBinding?.elementId === changedElement.id || + boundElement.endBinding?.elementId === changedElement.id + ); +}; + +const getSimultaneouslyUpdatedElementIds = ( + simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined, +): Set => { + return new Set((simultaneouslyUpdated || []).map((element) => element.id)); +}; + +const updateBoundPoint = ( + linearElement: NonDeleted, + 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[], +): SuggestedBinding[] => { + const includedElementIds = new Set(elements.map(({ id }) => id)); + return elements.flatMap((element) => + isBindingElement(element) + ? (getElligibleElementsForBindingElement( + element as NonDeleted, + ).filter( + (element) => !includedElementIds.has(element.id), + ) as SuggestedBinding[]) + : isBindableElement(element) + ? getElligibleElementsForBindableElementAndWhere(element).filter( + (binding) => !includedElementIds.has(binding[0].id), + ) + : [], + ); +}; + +const getElligibleElementsForBindingElement = ( + linearElement: NonDeleted, +): NonDeleted[] => { + return [ + getElligibleElementForBindingElement(linearElement, "start"), + getElligibleElementForBindingElement(linearElement, "end"), + ].filter( + (element): element is NonDeleted => + element != null, + ); +}; + +const getElligibleElementForBindingElement = ( + linearElement: NonDeleted, + startOrEnd: "start" | "end", +): NonDeleted | null => { + return getElligibleElementForBindingElementAtCoors( + linearElement, + startOrEnd, + getLinearElementEdgeCoors(linearElement, startOrEnd), + ); +}; + +export const getElligibleElementForBindingElementAtCoors = ( + linearElement: NonDeleted, + startOrEnd: "start" | "end", + pointerCoords: { + x: number; + y: number; + }, +): NonDeleted | 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, + startOrEnd: "start" | "end", +): { x: number; y: number } => { + const index = startOrEnd === "start" ? 0 : -1; + return tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index), + ); +}; + +const getElligibleElementsForBindableElementAndWhere = ( + bindableElement: NonDeleted, +): 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, + startOrEnd: "start" | "end", + bindableElement: NonDeleted, +): 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, + // 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 = new Set(); + const allBindableElementIds: Set = 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, +): 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 = 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, +): PointBinding | null => { + if (binding == null || deletedElementIds.has(binding.elementId)) { + return null; + } + return binding; +}; diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 93b5c99c..0952e259 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -10,11 +10,14 @@ import { import { isLinearElement } from "./typeChecks"; import { rescalePoints } from "../points"; +// x and y position of top left corner, x and y position of bottom right corner +export type Bounds = readonly [number, number, number, number]; + // If the element is created from right to left, the width is going to be negative // This set of functions retrieves the absolute position of the 4 points. export const getElementAbsoluteCoords = ( element: ExcalidrawElement, -): [number, number, number, number] => { +): Bounds => { if (isLinearElement(element)) { return getLinearElementAbsoluteCoords(element); } @@ -26,6 +29,13 @@ export const getElementAbsoluteCoords = ( ]; }; +export const pointRelativeTo = ( + element: ExcalidrawElement, + absoluteCoords: Point, +): Point => { + return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; +}; + export const getDiamondPoints = (element: ExcalidrawElement) => { // Here we add +1 to avoid these numbers to be 0 // otherwise rough.js will throw an error complaining about it @@ -35,7 +45,7 @@ export const getDiamondPoints = (element: ExcalidrawElement) => { const rightY = Math.floor(element.height / 2) + 1; const bottomX = topX; const bottomY = element.height; - const leftX = topY; + const leftX = 0; const leftY = rightY; return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; diff --git a/src/element/collision.ts b/src/element/collision.ts index 79fa4f38..8faa5553 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -1,23 +1,28 @@ -import { - distanceBetweenPointAndSegment, - isPathALoop, - rotate, - isPointInPolygon, -} from "../math"; +import * as GA from "../ga"; +import * as GAPoint from "../gapoints"; +import * as GADirection from "../gadirections"; +import * as GALine from "../galines"; +import * as GATransform from "../gatransforms"; + +import { isPathALoop, isPointInPolygon, rotate } from "../math"; import { pointsOnBezierCurves } from "points-on-curve"; -import { NonDeletedExcalidrawElement } from "./types"; - import { - getDiamondPoints, - getElementAbsoluteCoords, - getCurvePathOps, -} from "./bounds"; + NonDeletedExcalidrawElement, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawRectangleElement, + ExcalidrawDiamondElement, + ExcalidrawTextElement, + ExcalidrawEllipseElement, + NonDeleted, +} from "./types"; + +import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import { Point } from "../types"; import { Drawable } from "roughjs/bin/core"; import { AppState } from "../types"; import { getShapeForElement } from "../renderer/renderElement"; -import { isLinearElement } from "./typeChecks"; const isElementDraggableFromInside = ( element: NonDeletedExcalidrawElement, @@ -40,179 +45,575 @@ export const hitTest = ( appState: AppState, x: number, y: number, - zoom: number, ): boolean => { - // For shapes that are composed of lines, we only enable point-selection when the distance - // of the click is less than x pixels of any of the lines that the shape is composed of - const lineThreshold = 10 / zoom; + // How many pixels off the shape boundary we still consider a hit + const threshold = 10 / appState.zoom; + const check = isElementDraggableFromInside(element, appState) + ? isInsideCheck + : isNearCheck; + const point: Point = [x, y]; + return hitTestPointAgainstElement({ element, point, threshold, check }); +}; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; - // reverse rotate the pointer - [x, y] = rotate(x, y, cx, cy, -element.angle); +export const bindingBorderTest = ( + element: NonDeleted, + { x, y }: { x: number; y: number }, +): boolean => { + const threshold = maxBindingGap(element, element.width, element.height); + const check = isOutsideCheck; + const point: Point = [x, y]; + return hitTestPointAgainstElement({ element, point, threshold, check }); +}; - if (element.type === "ellipse") { - // https://stackoverflow.com/a/46007540/232122 - const px = Math.abs(x - element.x - element.width / 2); - const py = Math.abs(y - element.y - element.height / 2); +export const maxBindingGap = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, +): number => { + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + // We make the bindable boundary bigger for bigger elements + return Math.max(15, Math.min(0.25 * smallerDimension, 80)); +}; - let tx = 0.707; - let ty = 0.707; +type HitTestArgs = { + element: NonDeletedExcalidrawElement; + point: Point; + threshold: number; + check: (distance: number, threshold: number) => boolean; +}; - const a = Math.abs(element.width) / 2; - const b = Math.abs(element.height) / 2; - - [0, 1, 2, 3].forEach((x) => { - 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; - }); - - if (isElementDraggableFromInside(element, appState)) { - return ( - a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0 +const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { + switch (args.element.type) { + case "rectangle": + case "text": + case "diamond": + case "ellipse": + const distance = distanceToBindableElement(args.element, args.point); + return args.check(distance, args.threshold); + case "arrow": + case "line": + case "draw": + return hitTestLinear(args); + case "selection": + console.warn( + "This should not happen, we need to investigate why it does.", ); - } - return 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; - } - const shape = getShapeForElement(element) as Drawable[]; + } +}; - if ( - x < x1 - lineThreshold || - y < y1 - lineThreshold || - x > x2 + lineThreshold || - y > y2 + lineThreshold - ) { - return false; - } +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + point: Point, +): number => { + switch (element.type) { + case "rectangle": + case "text": + return distanceToRectangle(element, point); + case "diamond": + return distanceToDiamond(element, point); + case "ellipse": + return distanceToEllipse(element, point); + } +}; - const relX = x - element.x; - const relY = y - element.y; +const isInsideCheck = (distance: number, threshold: number): boolean => { + return distance < threshold; +}; - if (isElementDraggableFromInside(element, appState)) { - const hit = shape.some((subshape) => - hitTestCurveInside(subshape, relX, relY, lineThreshold), - ); - if (hit) { - return true; - } - } +const isNearCheck = (distance: number, threshold: number): boolean => { + return Math.abs(distance) < threshold; +}; - // hit thest all "subshapes" of the linear element - return shape.some((subshape) => - hitTestRoughShape(subshape, relX, relY, lineThreshold), - ); - } else if (element.type === "text") { - return x >= x1 && x <= x2 && y >= y1 && y <= y2; - } else if (element.type === "selection") { - console.warn("This should not happen, we need to investigate why it does."); +const isOutsideCheck = (distance: number, threshold: number): boolean => { + return 0 <= distance && distance < threshold; +}; + +const distanceToRectangle = ( + element: ExcalidrawRectangleElement | ExcalidrawTextElement, + point: Point, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const nearSide = + GAPoint.distanceToLine(pointRel, GALine.vector(hwidth, hheight)) > 0 + ? GALine.equation(0, 1, -hheight) + : GALine.equation(1, 0, -hwidth); + return GAPoint.distanceToLine(pointRel, nearSide); +}; + +const distanceToDiamond = ( + element: ExcalidrawDiamondElement, + point: Point, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const side = GALine.equation(hheight, hwidth, -hheight * hwidth); + return GAPoint.distanceToLine(pointRel, side); +}; + +const distanceToEllipse = ( + element: ExcalidrawEllipseElement, + point: Point, +): number => { + const [pointRel, tangent] = ellipseParamsForTest(element, point); + return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); +}; + +const ellipseParamsForTest = ( + element: ExcalidrawEllipseElement, + point: Point, +): [GA.Point, GA.Line] => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [px, py] = GAPoint.toTuple(pointRel); + + // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` + let tx = 0.707; + let ty = 0.707; + + const a = 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; } - 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 = ( diff --git a/src/element/dragElements.ts b/src/element/dragElements.ts index b32fab0a..92c21b22 100644 --- a/src/element/dragElements.ts +++ b/src/element/dragElements.ts @@ -1,20 +1,25 @@ -import { NonDeletedExcalidrawElement } from "./types"; +import { SHAPES } from "../shapes"; +import { updateBoundElements } from "./binding"; import { getCommonBounds } from "./bounds"; import { mutateElement } from "./mutateElement"; -import { SHAPES } from "../shapes"; import { getPerfectElementSize } from "./sizeHelpers"; +import Scene from "../scene/Scene"; +import { NonDeletedExcalidrawElement } from "./types"; export const dragSelectedElements = ( selectedElements: NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, + scene: Scene, ) => { const [x1, y1] = getCommonBounds(selectedElements); + const offset = { x: pointerX - x1, y: pointerY - y1 }; selectedElements.forEach((element) => { mutateElement(element, { - x: pointerX + element.x - x1, - y: pointerY + element.y - y1, + x: element.x + offset.x, + y: element.y + offset.y, }); + updateBoundElements(element, { simultaneouslyUpdated: selectedElements }); }); }; diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index 2cc3d09d..227d267d 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -1,10 +1,14 @@ import { ExcalidrawElement, PointerType } from "./types"; -import { getElementAbsoluteCoords } from "./bounds"; +import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { rotate } from "../math"; type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation"; +export type Handlers = Partial< + { [T in Sides]: [number, number, number, number] } +>; + const handleSizes: { [k in PointerType]: number } = { mouse: 8, pen: 16, @@ -61,12 +65,12 @@ const generateHandler = ( }; export const handlerRectanglesFromCoords = ( - [x1, y1, x2, y2]: [number, number, number, number], + [x1, y1, x2, y2]: Bounds, angle: number, zoom: number, pointerType: PointerType = "mouse", omitSides: { [T in Sides]?: boolean } = {}, -): Partial<{ [T in Sides]: [number, number, number, number] }> => { +): Handlers => { const size = handleSizes[pointerType]; const handlerWidth = size / zoom; const handlerHeight = size / zoom; diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index 468d2053..2ba358ca 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -2,6 +2,8 @@ import { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, + PointBinding, + ExcalidrawBindableElement, } from "./types"; import { distance2d, rotate, isPathALoop, getGridPoint } from "../math"; import { getElementAbsoluteCoords } from "."; @@ -11,6 +13,13 @@ import { mutateElement } from "./mutateElement"; import { SceneHistory } from "../history"; import Scene from "../scene/Scene"; +import { + bindOrUnbindLinearElement, + getHoveredElementForBinding, + isBindingEnabled, +} from "./binding"; +import { tupleToCoors } from "../utils"; +import { isBindingElement } from "./typeChecks"; export class LinearElementEditor { public elementId: ExcalidrawElement["id"] & { @@ -21,6 +30,8 @@ export class LinearElementEditor { public isDragging: boolean; public lastUncommittedPoint: Point | null; public pointerOffset: { x: number; y: number }; + public startBindingElement: ExcalidrawBindableElement | null | "keep"; + public endBindingElement: ExcalidrawBindableElement | null | "keep"; constructor(element: NonDeleted, scene: Scene) { this.elementId = element.id as string & { @@ -33,6 +44,8 @@ export class LinearElementEditor { this.lastUncommittedPoint = null; this.isDragging = false; this.pointerOffset = { x: 0, y: 0 }; + this.startBindingElement = "keep"; + this.endBindingElement = "keep"; } // --------------------------------------------------------------------------- @@ -59,6 +72,10 @@ export class LinearElementEditor { setState: React.Component["setState"], scenePointerX: number, scenePointerY: number, + maybeSuggestBinding: ( + element: NonDeleted, + startOrEnd: "start" | "end", + ) => void, ): boolean { if (!appState.editingLinearElement) { return false; @@ -88,13 +105,18 @@ export class LinearElementEditor { appState.gridSize, ); LinearElementEditor.movePoint(element, activePointIndex, newPoint); + if (isBindingElement(element)) { + maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end"); + } return true; } return false; } static handlePointerUp( + event: PointerEvent, editingLinearElement: LinearElementEditor, + appState: AppState, ): LinearElementEditor { const { elementId, activePointIndex, isDragging } = editingLinearElement; const element = LinearElementEditor.getElement(elementId); @@ -102,22 +124,40 @@ export class LinearElementEditor { return editingLinearElement; } + let binding = {}; if ( isDragging && - (activePointIndex === 0 || - activePointIndex === element.points.length - 1) && - isPathALoop(element.points) + (activePointIndex === 0 || activePointIndex === element.points.length - 1) ) { - LinearElementEditor.movePoint( - element, - activePointIndex, - activePointIndex === 0 - ? element.points[element.points.length - 1] - : element.points[0], - ); + if (isPathALoop(element.points)) { + LinearElementEditor.movePoint( + element, + activePointIndex, + activePointIndex === 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 { ...editingLinearElement, + ...binding, isDragging: false, pointerOffset: { x: 0, y: 0 }, }; @@ -128,8 +168,7 @@ export class LinearElementEditor { appState: AppState, setState: React.Component["setState"], history: SceneHistory, - scenePointerX: number, - scenePointerY: number, + scenePointer: { x: number; y: number }, ): { didAddPoint: boolean; hitElement: ExcalidrawElement | null; @@ -151,14 +190,14 @@ export class LinearElementEditor { } if (event.altKey) { - if (!appState.editingLinearElement.lastUncommittedPoint) { + if (appState.editingLinearElement.lastUncommittedPoint == null) { mutateElement(element, { points: [ ...element.points, LinearElementEditor.createPointAt( element, - scenePointerX, - scenePointerY, + scenePointer.x, + scenePointer.y, appState.gridSize, ), ], @@ -170,6 +209,10 @@ export class LinearElementEditor { ...appState.editingLinearElement, activePointIndex: element.points.length - 1, lastUncommittedPoint: null, + endBindingElement: getHoveredElementForBinding( + scenePointer, + Scene.getScene(element)!, + ), }, }); ret.didAddPoint = true; @@ -179,14 +222,31 @@ export class LinearElementEditor { const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, appState.zoom, - scenePointerX, - scenePointerY, + scenePointer.x, + scenePointer.y, ); // if we clicked on a point, set the element as hitElement otherwise // it would get deselected if the point is outside the hitbox area if (clickedPointIndex > -1) { ret.hitElement = element; + } else { + // You might be wandering why we are storing the binding elements on + // LinearElementEditor and passing them in, insted of calculating them + // from the end points of the `linearElement` - this is to allow disabling + // binding (which needs to happen at the point the user finishes moving + // the point). + const { + startBindingElement, + endBindingElement, + } = appState.editingLinearElement; + if (isBindingEnabled(appState) && isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + ); + } } const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); @@ -208,8 +268,8 @@ export class LinearElementEditor { activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null, pointerOffset: targetPoint ? { - x: scenePointerX - targetPoint[0], - y: scenePointerY - targetPoint[1], + x: scenePointer.x - targetPoint[0], + y: scenePointer.y - targetPoint[1], } : { x: 0, y: 0 }, }, @@ -237,7 +297,7 @@ export class LinearElementEditor { if (lastPoint === lastUncommittedPoint) { LinearElementEditor.movePoint(element, points.length - 1, "delete"); } - return editingLinearElement; + return { ...editingLinearElement, lastUncommittedPoint: null }; } const newPoint = LinearElementEditor.createPointAt( @@ -276,6 +336,40 @@ export class LinearElementEditor { }); } + static getPointAtIndexGlobalCoordinates( + element: NonDeleted, + 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, + absoluteCoords: Point, + ): Point { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [x, y] = rotate( + absoluteCoords[0], + absoluteCoords[1], + cx, + cy, + -element.angle, + ); + return [x - element.x, y - element.y]; + } + static getPointIndexUnderCursor( element: NonDeleted, zoom: AppState["zoom"], @@ -343,10 +437,23 @@ export class LinearElementEditor { }); } + static movePointByOffset( + element: NonDeleted, + pointIndex: number, + offset: { x: number; y: number }, + ) { + const [x, y] = element.points[pointIndex]; + LinearElementEditor.movePoint(element, pointIndex, [ + x + offset.x, + y + offset.y, + ]); + } + static movePoint( element: NonDeleted, pointIndex: number | "new", targetPosition: Point | "delete", + otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, ) { const { points } = element; @@ -412,6 +519,7 @@ export class LinearElementEditor { const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); mutateElement(element, { + ...otherUpdates, points: nextPoints, x: element.x + rotated[0], y: element.y + rotated[1], diff --git a/src/element/newElement.ts b/src/element/newElement.ts index e9af4f8a..ba71fa77 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -24,6 +24,7 @@ type ElementConstructorOpts = MarkOptional< | "height" | "angle" | "groupIds" + | "boundElementIds" | "seed" | "version" | "versionNonce" @@ -45,6 +46,7 @@ const _newElementBase = ( height = 0, angle = 0, groupIds = [], + boundElementIds = null, ...rest }: ElementConstructorOpts & Omit, "type">, ) => ({ @@ -67,6 +69,7 @@ const _newElementBase = ( version: rest.version || 1, versionNonce: rest.versionNonce ?? 0, isDeleted: false as false, + boundElementIds, }); export const newElement = ( @@ -215,6 +218,8 @@ export const newLinearElement = ( ..._newElementBase(opts.type, opts), points: [], lastCommittedPoint: null, + startBinding: null, + endBinding: null, }; }; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 60a4195e..2bc5ff3c 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -22,6 +22,7 @@ import { normalizeResizeHandle, } from "./resizeTest"; import { measureText, getFontString } from "../utils"; +import { updateBoundElements } from "./binding"; const normalizeAngle = (angle: number): number => { if (angle >= 2 * Math.PI) { @@ -32,6 +33,7 @@ const normalizeAngle = (angle: number): number => { type ResizeTestType = ReturnType; +// Returns true when a resize (scaling/rotation) happened export const resizeElements = ( resizeHandle: ResizeTestType, setResizeHandle: (nextResizeHandle: ResizeTestType) => void, @@ -55,6 +57,7 @@ export const resizeElements = ( pointerY, isRotateWithDiscreteAngle, ); + updateBoundElements(element); } else if ( isLinearElement(element) && element.points.length === 2 && @@ -404,6 +407,9 @@ const resizeSingleElement = ( const deltaX2 = (x2 - nextX2) / 2; const deltaY2 = (y2 - nextY2) / 2; const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight); + updateBoundElements(element, { + newSize: { width: nextWidth, height: nextHeight }, + }); const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords( { ...element, @@ -530,6 +536,10 @@ const resizeMultipleElements = ( } const origCoords = getElementAbsoluteCoords(element); const rescaledPoints = rescalePointsInElement(element, width, height); + updateBoundElements(element, { + newSize: { width, height }, + simultaneouslyUpdated: elements, + }); const finalCoords = getResizedElementAbsoluteCoords( { ...element, diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 10a40e8b..646ca7ae 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -2,6 +2,7 @@ import { ExcalidrawElement, ExcalidrawTextElement, ExcalidrawLinearElement, + ExcalidrawBindableElement, } from "./types"; export const isTextElement = ( @@ -13,11 +14,38 @@ export const isTextElement = ( export const isLinearElement = ( element?: ExcalidrawElement | null, ): element is ExcalidrawLinearElement => { + return element != null && isLinearElementType(element.type); +}; + +export const isLinearElementType = ( + elementType: ExcalidrawElement["type"], +): boolean => { + return ( + elementType === "arrow" || elementType === "line" || elementType === "draw" + ); +}; + +export const isBindingElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawLinearElement => { + return element != null && isBindingElementType(element.type); +}; + +export const isBindingElementType = ( + elementType: ExcalidrawElement["type"], +): boolean => { + return elementType === "arrow"; +}; + +export const isBindableElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawBindableElement => { return ( element != null && - (element.type === "arrow" || - element.type === "line" || - element.type === "draw") + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "ellipse" || + element.type === "text") ); }; diff --git a/src/element/types.ts b/src/element/types.ts index 54e76ba5..d92e27cd 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -22,19 +22,33 @@ type _ExcalidrawElementBase = Readonly<{ versionNonce: number; isDeleted: boolean; groupIds: readonly GroupId[]; + boundElementIds: readonly ExcalidrawLinearElement["id"][] | null; }>; export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { type: "selection"; }; + +export type ExcalidrawRectangleElement = _ExcalidrawElementBase & { + type: "rectangle"; +}; + +export type ExcalidrawDiamondElement = _ExcalidrawElementBase & { + type: "diamond"; +}; + +export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { + type: "ellipse"; +}; + /** * These are elements that don't have any additional properties. */ export type ExcalidrawGenericElement = | ExcalidrawSelectionElement - | (_ExcalidrawElementBase & { - type: "rectangle" | "diamond" | "ellipse"; - }); + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement; /** * ExcalidrawElement should be JSON serializable and (eventually) contain @@ -63,11 +77,25 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; }>; +export type ExcalidrawBindableElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement + | ExcalidrawTextElement; + +export type PointBinding = { + elementId: ExcalidrawBindableElement["id"]; + focus: number; + gap: number; +}; + export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ type: "arrow" | "line" | "draw"; points: readonly Point[]; lastCommittedPoint: Point | null; + startBinding: PointBinding | null; + endBinding: PointBinding | null; }>; export type PointerType = "mouse" | "pen" | "touch"; diff --git a/src/ga.ts b/src/ga.ts new file mode 100644 index 00000000..6a6f5be3 --- /dev/null +++ b/src/ga.ts @@ -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; diff --git a/src/gadirections.ts b/src/gadirections.ts new file mode 100644 index 00000000..0307c9bf --- /dev/null +++ b/src/gadirections.ts @@ -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); +} diff --git a/src/galines.ts b/src/galines.ts new file mode 100644 index 00000000..9d83b9e5 --- /dev/null +++ b/src/galines.ts @@ -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]); +} diff --git a/src/gapoints.ts b/src/gapoints.ts new file mode 100644 index 00000000..8d749468 --- /dev/null +++ b/src/gapoints.ts @@ -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); +} diff --git a/src/gatransforms.ts b/src/gatransforms.ts new file mode 100644 index 00000000..2a1d5f09 --- /dev/null +++ b/src/gatransforms.ts @@ -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))); +} diff --git a/src/math.ts b/src/math.ts index ae5ade72..e60b8b0d 100644 --- a/src/math.ts +++ b/src/math.ts @@ -2,45 +2,6 @@ import { Point } from "./types"; import { LINE_CONFIRM_THRESHOLD } from "./constants"; import { ExcalidrawLinearElement } from "./element/types"; -// https://stackoverflow.com/a/6853926/232122 -export const distanceBetweenPointAndSegment = ( - x: number, - y: number, - x1: number, - y1: number, - x2: number, - y2: number, -) => { - const A = x - x1; - const B = y - y1; - const C = x2 - x1; - const D = y2 - y1; - - const dot = A * C + B * D; - const lenSquare = C * C + D * D; - let param = -1; - if (lenSquare !== 0) { - // in case of 0 length line - param = dot / lenSquare; - } - - let xx, yy; - if (param < 0) { - xx = x1; - yy = y1; - } else if (param > 1) { - xx = x2; - yy = y2; - } else { - xx = x1 + param * C; - yy = y1 + param * D; - } - - const dx = x - xx; - const dy = y - yy; - return Math.hypot(dx, dy); -}; - export const rotate = ( x1: number, y1: number, @@ -230,6 +191,10 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { return Math.hypot(xd, yd); }; +export const centerPoint = (a: Point, b: Point): Point => { + return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; +}; + // Checks if the first and last point are close enough // to be considered a loop export const isPathALoop = ( @@ -265,9 +230,9 @@ export const isPointInPolygon = ( for (let i = 0; i < vertices; i++) { const current = points[i]; const next = points[(i + 1) % vertices]; - if (doIntersect(current, next, p, extreme)) { - if (orientation(current, p, next) === 0) { - return onSegment(current, p, next); + if (doSegmentsIntersect(current, next, p, extreme)) { + if (orderedColinearOrientation(current, p, next) === 0) { + return isPointWithinBounds(current, p, next); } count++; } @@ -276,8 +241,9 @@ export const isPointInPolygon = ( return count % 2 === 1; }; -// Check if q lies on the line segment pr -const onSegment = (p: Point, q: Point, r: Point) => { +// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. +// This is an approximation to "does `q` lie on a segment `pr`" check. +const isPointWithinBounds = (p: Point, q: Point, r: Point) => { return ( q[0] <= Math.max(p[0], r[0]) && q[0] >= Math.min(p[0], r[0]) && @@ -287,10 +253,10 @@ const onSegment = (p: Point, q: Point, r: Point) => { }; // For the ordered points p, q, r, return -// 0 if p, q, r are collinear +// 0 if p, q, r are colinear // 1 if Clockwise // 2 if counterclickwise -const orientation = (p: Point, q: Point, r: Point) => { +const orderedColinearOrientation = (p: Point, q: Point, r: Point) => { const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]); if (val === 0) { return 0; @@ -299,33 +265,33 @@ const orientation = (p: Point, q: Point, r: Point) => { }; // Check is p1q1 intersects with p2q2 -const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { - const o1 = orientation(p1, q1, p2); - const o2 = orientation(p1, q1, q2); - const o3 = orientation(p2, q2, p1); - const o4 = orientation(p2, q2, q1); +const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { + const o1 = orderedColinearOrientation(p1, q1, p2); + const o2 = orderedColinearOrientation(p1, q1, q2); + const o3 = orderedColinearOrientation(p2, q2, p1); + const o4 = orderedColinearOrientation(p2, q2, q1); if (o1 !== o2 && o3 !== o4) { return true; } // p1, q1 and p2 are colinear and p2 lies on segment p1q1 - if (o1 === 0 && onSegment(p1, p2, q1)) { + if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) { return true; } // p1, q1 and p2 are colinear and q2 lies on segment p1q1 - if (o2 === 0 && onSegment(p1, q2, q1)) { + if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) { return true; } // p2, q2 and p1 are colinear and p1 lies on segment p2q2 - if (o3 === 0 && onSegment(p2, p1, q2)) { + if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) { return true; } // p2, q2 and q1 are colinear and q1 lies on segment p2q2 - if (o4 === 0 && onSegment(p2, q1, q2)) { + if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) { return true; } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 12b74d03..ea5d8c14 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -9,6 +9,7 @@ import { ExcalidrawLinearElement, NonDeleted, GroupId, + ExcalidrawBindableElement, } from "../element/types"; import { getElementAbsoluteCoords, @@ -36,6 +37,13 @@ import { getSelectedGroupIds, getElementsInGroup, } from "../groups"; +import { maxBindingGap } from "../element/collision"; +import { + SuggestedBinding, + SuggestedPointBinding, + isBindingEnabled, +} from "../element/binding"; +import { Handlers } from "../element/handlerRectangles"; type HandlerRectanglesRet = keyof ReturnType; @@ -48,7 +56,7 @@ const strokeRectWithRotation = ( cx: number, cy: number, angle: number, - fill?: boolean, + fill: boolean = false, ) => { context.translate(cx, cy); context.rotate(angle); @@ -60,15 +68,48 @@ const strokeRectWithRotation = ( context.translate(-cx, -cy); }; -const strokeCircle = ( +const strokeDiamondWithRotation = ( context: CanvasRenderingContext2D, - x: number, - y: number, width: number, height: number, + cx: number, + cy: number, + angle: number, +) => { + context.translate(cx, cy); + context.rotate(angle); + context.beginPath(); + context.moveTo(0, height / 2); + context.lineTo(width / 2, 0); + context.lineTo(0, -height / 2); + context.lineTo(-width / 2, 0); + context.closePath(); + context.stroke(); + context.rotate(-angle); + context.translate(-cx, -cy); +}; + +const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, ) => { context.beginPath(); - context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); context.fill(); context.stroke(); }; @@ -116,12 +157,11 @@ const renderLinearPointHandles = ( ? "rgba(255, 127, 127, 0.9)" : "rgba(255, 255, 255, 0.9)"; const { POINT_HANDLE_SIZE } = LinearElementEditor; - strokeCircle( + fillCircle( context, - point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom, - point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom, - POINT_HANDLE_SIZE / sceneState.zoom, - POINT_HANDLE_SIZE / sceneState.zoom, + point[0], + point[1], + POINT_HANDLE_SIZE / 2 / sceneState.zoom, ); }, ); @@ -241,14 +281,20 @@ export const renderScene = ( ); } + if (isBindingEnabled(appState)) { + appState.suggestedBindings + .filter((binding) => binding != null) + .forEach((suggestedBinding) => { + renderBindingHighlight(context, sceneState, suggestedBinding!); + }); + } + // Paint selected elements if ( renderSelection && !appState.multiElement && !appState.editingLinearElement ) { - context.translate(sceneState.scrollX, sceneState.scrollY); - const selections = elements.reduce((acc, element) => { const selectionColors = []; // local user @@ -310,99 +356,28 @@ export const renderScene = ( addSelectionForGroupId(appState.editingGroupId); } - selections.forEach( - ({ - angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - }) => { - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - - const initialLineDash = context.getLineDash(); - const lineWidth = context.lineWidth; - const lineDashOffset = context.lineDashOffset; - const strokeStyle = context.strokeStyle; - - const dashedLinePadding = 4 / sceneState.zoom; - const dashWidth = 8 / sceneState.zoom; - const spaceWidth = 4 / sceneState.zoom; - - context.lineWidth = 1 / sceneState.zoom; - - const count = selectionColors.length; - for (var i = 0; i < count; ++i) { - context.strokeStyle = selectionColors[i]; - context.setLineDash([ - dashWidth, - spaceWidth + (dashWidth + spaceWidth) * (count - 1), - ]); - context.lineDashOffset = (dashWidth + spaceWidth) * i; - strokeRectWithRotation( - context, - elementX1 - dashedLinePadding, - elementY1 - dashedLinePadding, - elementWidth + dashedLinePadding * 2, - elementHeight + dashedLinePadding * 2, - elementX1 + elementWidth / 2, - elementY1 + elementHeight / 2, - angle, - ); - } - context.lineDashOffset = lineDashOffset; - context.strokeStyle = strokeStyle; - context.lineWidth = lineWidth; - context.setLineDash(initialLineDash); - }, + selections.forEach((selection) => + renderSelectionBorder(context, sceneState, selection), ); - context.translate(-sceneState.scrollX, -sceneState.scrollY); const locallySelectedElements = getSelectedElements(elements, appState); // Paint resize handlers + context.translate(sceneState.scrollX, sceneState.scrollY); if (locallySelectedElements.length === 1) { - context.translate(sceneState.scrollX, sceneState.scrollY); context.fillStyle = oc.white; const handlers = handlerRectangles( locallySelectedElements[0], sceneState.zoom, ); - Object.keys(handlers).forEach((key) => { - const handler = handlers[key as HandlerRectanglesRet]; - if (handler !== undefined) { - const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; - if (key === "rotation") { - strokeCircle( - 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); + renderHandlers( + context, + sceneState, + handlers, + locallySelectedElements[0].angle, + ); } else if (locallySelectedElements.length > 1 && !appState.isRotating) { const dashedLinePadding = 4 / sceneState.zoom; - context.translate(sceneState.scrollX, sceneState.scrollY); context.fillStyle = oc.white; const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); const initialLineDash = context.getLineDash(); @@ -428,37 +403,9 @@ export const renderScene = ( undefined, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, ); - Object.keys(handlers).forEach((key) => { - const handler = handlers[key as HandlerRectanglesRet]; - if (handler !== undefined) { - const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; - if (key === "rotation") { - strokeCircle( - context, - handler[0], - handler[1], - handler[2], - handler[3], - ); - } else { - strokeRectWithRotation( - context, - handler[0], - handler[1], - handler[2], - handler[3], - handler[0] + handler[2] / 2, - handler[1] + handler[3] / 2, - 0, - true, // fill before stroke - ); - } - context.lineWidth = lineWidth; - } - }); - context.translate(-sceneState.scrollX, -sceneState.scrollY); + renderHandlers(context, sceneState, handlers, 0); } + context.translate(-sceneState.scrollX, -sceneState.scrollY); } // Reset zoom @@ -598,6 +545,207 @@ export const renderScene = ( return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; }; +const renderHandlers = ( + context: CanvasRenderingContext2D, + sceneState: SceneState, + handlers: Handlers, + angle: number, +): void => { + Object.keys(handlers).forEach((key) => { + const handler = handlers[key as HandlerRectanglesRet]; + if (handler !== undefined) { + const lineWidth = context.lineWidth; + context.lineWidth = 1 / sceneState.zoom; + if (key === "rotation") { + fillCircle( + context, + handler[0] + handler[2] / 2, + handler[1] + handler[3] / 2, + handler[2] / 2, + ); + } else { + strokeRectWithRotation( + context, + handler[0], + handler[1], + handler[2], + handler[3], + handler[0] + handler[2] / 2, + handler[1] + handler[3] / 2, + angle, + true, // fill before stroke + ); + } + context.lineWidth = lineWidth; + } + }); +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + sceneState: SceneState, + elementProperties: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + }, +) => { + const { + angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + } = elementProperties; + const elementWidth = elementX2 - elementX1; + const elementHeight = elementY2 - elementY1; + + const initialLineDash = context.getLineDash(); + const lineWidth = context.lineWidth; + const lineDashOffset = context.lineDashOffset; + const strokeStyle = context.strokeStyle; + + const dashedLinePadding = 4 / sceneState.zoom; + const dashWidth = 8 / sceneState.zoom; + const spaceWidth = 4 / sceneState.zoom; + + context.lineWidth = 1 / sceneState.zoom; + + context.translate(sceneState.scrollX, sceneState.scrollY); + + const count = selectionColors.length; + for (var i = 0; i < count; ++i) { + context.strokeStyle = selectionColors[i]; + context.setLineDash([ + dashWidth, + spaceWidth + (dashWidth + spaceWidth) * (count - 1), + ]); + context.lineDashOffset = (dashWidth + spaceWidth) * i; + strokeRectWithRotation( + context, + elementX1 - dashedLinePadding, + elementY1 - dashedLinePadding, + elementWidth + dashedLinePadding * 2, + elementHeight + dashedLinePadding * 2, + elementX1 + elementWidth / 2, + elementY1 + elementHeight / 2, + angle, + ); + } + context.lineDashOffset = lineDashOffset; + context.strokeStyle = strokeStyle; + context.lineWidth = lineWidth; + context.setLineDash(initialLineDash); + context.translate(-sceneState.scrollX, -sceneState.scrollY); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + sceneState: SceneState, + suggestedBinding: SuggestedBinding, +) => { + // preserve context settings to restore later + const originalStrokeStyle = context.strokeStyle; + const originalLineWidth = context.lineWidth; + + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.translate(sceneState.scrollX, sceneState.scrollY); + renderHighlight(context, suggestedBinding as any); + + // restore context settings + context.strokeStyle = originalStrokeStyle; + context.lineWidth = originalLineWidth; + context.translate(-sceneState.scrollX, -sceneState.scrollY); +}; + +const renderBindingHighlightForBindableElement = ( + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const width = x2 - x1; + const height = y2 - y1; + const threshold = maxBindingGap(element, width, height); + + // So that we don't overlap the element itself + const strokeOffset = 4; + context.strokeStyle = "rgba(0,0,0,.05)"; + context.lineWidth = threshold - strokeOffset; + const padding = strokeOffset + threshold / 2; + + switch (element.type) { + case "rectangle": + case "text": + strokeRectWithRotation( + context, + x1 - padding, + y1 - padding, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "diamond": + const side = Math.hypot(width, height); + const wPadding = (padding * side) / height; + const hPadding = (padding * side) / width; + strokeDiamondWithRotation( + context, + width + wPadding * 2, + height + hPadding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "ellipse": + strokeEllipseWithRotation( + context, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + } +}; + +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + ); + fillCircle(context, x, y, threshold); + }); +}; + const isVisibleElement = ( element: ExcalidrawElement, viewportWidth: number, diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 39cfc0c6..5c353481 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -52,10 +52,12 @@ class Scene { private elements: readonly ExcalidrawElement[] = []; private elementsMap = new Map(); + // TODO: getAllElementsIncludingDeleted getElementsIncludingDeleted() { return this.elements; } + // TODO: getAllNonDeletedElements getElements(): readonly NonDeletedExcalidrawElement[] { return this.nonDeletedElements; } @@ -74,6 +76,20 @@ class Scene { return null; } + // TODO: Rename methods here, this is confusing + getNonDeletedElements( + ids: readonly ExcalidrawElement["id"][], + ): NonDeleted[] { + const result: NonDeleted[] = []; + ids.forEach((id) => { + const element = this.getNonDeletedElement(id); + if (element != null) { + result.push(element); + } + }); + return result; + } + replaceAllElements(nextElements: readonly ExcalidrawElement[]) { this.elements = nextElements; this.elementsMap.clear(); diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index 3ab66e24..6956da51 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -3,8 +3,7 @@ import { NonDeletedExcalidrawElement, } from "../element/types"; -import { getElementAbsoluteCoords, hitTest } from "../element"; -import { AppState } from "../types"; +import { getElementAbsoluteCoords } from "../element"; export const hasBackground = (type: string) => type === "rectangle" || @@ -25,19 +24,17 @@ export const hasText = (type: string) => type === "text"; export const getElementAtPosition = ( elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - x: number, - y: number, - zoom: number, + isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, ) => { let hitElement = null; // We need to to hit testing from front (end of the array) to back (beginning of the array) for (let i = elements.length - 1; i >= 0; --i) { - if (elements[i].isDeleted) { + const element = elements[i]; + if (element.isDeleted) { continue; } - if (hitTest(elements[i], appState, x, y, zoom)) { - hitElement = elements[i]; + if (isAtPositionFn(element)) { + hitElement = element; break; } } diff --git a/src/tests/__snapshots__/dragCreate.test.tsx.snap b/src/tests/__snapshots__/dragCreate.test.tsx.snap index 26227494..ac0374c0 100644 --- a/src/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/src/tests/__snapshots__/dragCreate.test.tsx.snap @@ -6,6 +6,8 @@ exports[`add element to the scene when pointer dragging long enough arrow 2`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -25,6 +27,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -43,6 +46,7 @@ exports[`add element to the scene when pointer dragging long enough diamond 2`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -69,6 +73,7 @@ exports[`add element to the scene when pointer dragging long enough ellipse 2`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -93,6 +98,8 @@ exports[`add element to the scene when pointer dragging long enough line 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -112,6 +119,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -130,6 +138,7 @@ exports[`add element to the scene when pointer dragging long enough rectangle 2` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, diff --git a/src/tests/__snapshots__/move.test.tsx.snap b/src/tests/__snapshots__/move.test.tsx.snap index a07f1b22..9f73da20 100644 --- a/src/tests/__snapshots__/move.test.tsx.snap +++ b/src/tests/__snapshots__/move.test.tsx.snap @@ -4,6 +4,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -28,6 +29,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 2`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -52,6 +54,7 @@ exports[`move element rectangle 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, diff --git a/src/tests/__snapshots__/multiPointCreate.test.tsx.snap b/src/tests/__snapshots__/multiPointCreate.test.tsx.snap index 97165075..0ae60686 100644 --- a/src/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/src/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -4,6 +4,8 @@ exports[`multi point mode in linear elements arrow 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 110, @@ -30,6 +32,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -46,6 +49,8 @@ exports[`multi point mode in linear elements line 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 110, @@ -72,6 +77,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 10d6ac4d..524c515a 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -26,6 +26,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -58,6 +59,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -70,6 +73,7 @@ exports[`regression tests adjusts z order when grouping: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -94,6 +98,7 @@ exports[`regression tests adjusts z order when grouping: [end of test] element 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -120,6 +125,7 @@ exports[`regression tests adjusts z order when grouping: [end of test] element 2 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -161,6 +167,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -195,6 +202,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -216,6 +224,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -250,6 +259,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -271,6 +281,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -292,6 +303,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -329,6 +341,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -350,6 +363,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -373,6 +387,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -429,6 +444,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -456,6 +472,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -468,6 +486,7 @@ exports[`regression tests alt-drag duplicates an element: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -492,6 +511,7 @@ exports[`regression tests alt-drag duplicates an element: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -531,6 +551,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -566,6 +587,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -587,6 +609,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -641,6 +664,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -665,6 +689,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -677,6 +703,7 @@ exports[`regression tests arrow keys: [end of test] element 0 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -716,6 +743,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -770,6 +798,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -794,6 +823,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -806,6 +837,7 @@ exports[`regression tests change the properties of a shape: [end of test] elemen Object { "angle": 0, "backgroundColor": "#fa5252", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -845,6 +877,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -879,6 +912,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -913,6 +947,7 @@ Object { Object { "angle": 0, "backgroundColor": "#fa5252", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -947,6 +982,7 @@ Object { Object { "angle": 0, "backgroundColor": "#fa5252", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -981,6 +1017,7 @@ Object { Object { "angle": 0, "backgroundColor": "#fa5252", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1035,6 +1072,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -1062,6 +1100,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -1074,6 +1114,7 @@ exports[`regression tests click on an element and drag it: [dragged] element 0 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1113,6 +1154,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1148,6 +1190,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1202,6 +1245,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -1231,6 +1275,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -1243,6 +1289,7 @@ exports[`regression tests click on an element and drag it: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1282,6 +1329,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1317,6 +1365,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1353,6 +1402,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1407,6 +1457,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -1434,6 +1485,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -1446,6 +1499,7 @@ exports[`regression tests click to select a shape: [end of test] element 0 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1470,6 +1524,7 @@ exports[`regression tests click to select a shape: [end of test] element 1 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1509,6 +1564,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1543,6 +1599,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1564,6 +1621,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1618,6 +1676,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -1646,6 +1705,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -1658,6 +1719,7 @@ exports[`regression tests click-drag to select a group: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1682,6 +1744,7 @@ exports[`regression tests click-drag to select a group: [end of test] element 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1706,6 +1769,7 @@ exports[`regression tests click-drag to select a group: [end of test] element 2 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1745,6 +1809,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1779,6 +1844,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1800,6 +1866,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1834,6 +1901,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1855,6 +1923,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1876,6 +1945,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -1930,6 +2000,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -1954,6 +2025,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -1966,6 +2039,7 @@ exports[`regression tests double click to edit a group: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -1992,6 +2066,7 @@ exports[`regression tests double click to edit a group: [end of test] element 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -2018,6 +2093,7 @@ exports[`regression tests double click to edit a group: [end of test] element 2 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -2059,6 +2135,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2093,6 +2170,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2114,6 +2192,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2148,6 +2227,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2169,6 +2249,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2190,6 +2271,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2226,6 +2308,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -2249,6 +2332,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -2272,6 +2356,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -2328,6 +2413,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -2352,6 +2438,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -2364,6 +2452,7 @@ exports[`regression tests draw every type of shape: [end of test] element 0 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2378,9 +2467,9 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, } `; @@ -2388,6 +2477,7 @@ exports[`regression tests draw every type of shape: [end of test] element 1 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2402,9 +2492,9 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, } `; @@ -2412,6 +2502,7 @@ exports[`regression tests draw every type of shape: [end of test] element 2 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2426,9 +2517,9 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, } `; @@ -2436,6 +2527,8 @@ exports[`regression tests draw every type of shape: [end of test] element 3 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2449,21 +2542,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, } `; @@ -2471,6 +2565,8 @@ exports[`regression tests draw every type of shape: [end of test] element 4 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2484,21 +2580,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, } `; @@ -2506,13 +2603,15 @@ exports[`regression tests draw every type of shape: [end of test] element 5 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -2522,25 +2621,26 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 1723083209, - "width": 10, - "x": 110, - "y": 10, + "width": 80, + "x": 310, + "y": -10, } `; @@ -2548,13 +2648,15 @@ exports[`regression tests draw every type of shape: [end of test] element 6 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id6", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -2564,25 +2666,26 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 760410951, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 7, "versionNonce": 406373543, - "width": 10, - "x": 120, - "y": 10, + "width": 80, + "x": 430, + "y": -10, } `; @@ -2590,6 +2693,8 @@ exports[`regression tests draw every type of shape: [end of test] element 7 1`] Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2603,21 +2708,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 941653321, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "draw", "version": 3, "versionNonce": 1402203177, - "width": 10, - "x": 130, - "y": 10, + "width": 50, + "x": 550, + "y": -10, } `; @@ -2650,6 +2756,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2664,9 +2771,9 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, ], }, @@ -2684,6 +2791,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2698,13 +2806,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2719,9 +2828,9 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, ], }, @@ -2739,6 +2848,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2753,13 +2863,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2774,13 +2885,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2795,9 +2907,9 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, ], }, @@ -2815,6 +2927,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2829,13 +2942,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2850,13 +2964,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2871,13 +2986,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2891,21 +3008,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, ], }, @@ -2923,6 +3041,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2937,13 +3056,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2958,13 +3078,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2979,13 +3100,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -2999,25 +3122,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3031,21 +3157,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, ], }, @@ -3063,6 +3190,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3077,13 +3205,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3098,13 +3227,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3119,13 +3249,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3139,25 +3271,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3171,32 +3306,35 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 10, + 50, 10, ], "opacity": 100, @@ -3206,21 +3344,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 5, "versionNonce": 81784553, - "width": 10, - "x": 110, - "y": 10, + "width": 50, + "x": 310, + "y": -10, }, ], }, @@ -3238,6 +3377,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3252,13 +3392,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3273,13 +3414,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3294,13 +3436,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3314,25 +3458,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3346,32 +3493,35 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -3381,25 +3531,26 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 1723083209, - "width": 10, - "x": 110, - "y": 10, + "width": 80, + "x": 310, + "y": -10, }, ], }, @@ -3417,6 +3568,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3431,13 +3583,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3452,13 +3605,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3473,13 +3627,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3493,25 +3649,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3525,32 +3684,35 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -3560,36 +3722,39 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 1723083209, - "width": 10, - "x": 110, - "y": 10, + "width": 80, + "x": 310, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, "id": "id6", "isDeleted": false, "lastCommittedPoint": Array [ - 10, + 50, 10, ], "opacity": 100, @@ -3599,21 +3764,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 760410951, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 5, "versionNonce": 1898319239, - "width": 10, - "x": 120, - "y": 10, + "width": 50, + "x": 430, + "y": -10, }, ], }, @@ -3631,6 +3797,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3645,13 +3812,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3666,13 +3834,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3687,13 +3856,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3707,25 +3878,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3739,32 +3913,35 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -3774,36 +3951,39 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 1723083209, - "width": 10, - "x": 110, - "y": 10, + "width": 80, + "x": 310, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id6", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -3813,25 +3993,26 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 760410951, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 7, "versionNonce": 406373543, - "width": 10, - "x": 120, - "y": 10, + "width": 80, + "x": 430, + "y": -10, }, ], }, @@ -3849,6 +4030,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3863,13 +4045,14 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3884,13 +4067,14 @@ Object { "type": "diamond", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 20, + "x": 40, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3905,13 +4089,15 @@ Object { "type": "ellipse", "version": 2, "versionNonce": 2019559783, - "width": 10, - "x": 50, - "y": 10, + "width": 20, + "x": 70, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3925,25 +4111,28 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 1150084233, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 3, "versionNonce": 1014066025, - "width": 10, - "x": 70, - "y": 10, + "width": 50, + "x": 130, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -3957,32 +4146,35 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 238820263, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 3, "versionNonce": 1604849351, - "width": 10, - "x": 90, - "y": 10, + "width": 50, + "x": 220, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id5", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -3992,36 +4184,39 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 1505387817, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 1723083209, - "width": 10, - "x": 110, - "y": 10, + "width": 80, + "x": 310, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id6", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 80, 20, ], "opacity": 100, @@ -4031,29 +4226,32 @@ Object { 0, ], Array [ - 10, + 50, 10, ], Array [ - 0, + 80, 20, ], ], "roughness": 1, "seed": 760410951, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "line", "version": 7, "versionNonce": 406373543, - "width": 10, - "x": 120, - "y": 10, + "width": 80, + "x": 430, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4067,21 +4265,22 @@ Object { 0, ], Array [ - 10, + 50, 10, ], ], "roughness": 1, "seed": 941653321, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "draw", "version": 3, "versionNonce": 1402203177, - "width": 10, - "x": 130, - "y": 10, + "width": 50, + "x": 550, + "y": -10, }, ], }, @@ -4091,7 +4290,7 @@ Object { exports[`regression tests draw every type of shape: [end of test] number of elements 1`] = `8`; -exports[`regression tests draw every type of shape: [end of test] number of renders 1`] = `47`; +exports[`regression tests draw every type of shape: [end of test] number of renders 1`] = `51`; exports[`regression tests hotkey 2 selects rectangle tool: [end of test] appState 1`] = ` Object { @@ -4119,6 +4318,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4143,6 +4343,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4155,6 +4357,7 @@ exports[`regression tests hotkey 2 selects rectangle tool: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4194,6 +4397,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4248,6 +4452,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4272,6 +4477,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4284,6 +4491,7 @@ exports[`regression tests hotkey 3 selects diamond tool: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4323,6 +4531,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4377,6 +4586,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4401,6 +4611,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4413,6 +4625,7 @@ exports[`regression tests hotkey 4 selects ellipse tool: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4452,6 +4665,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4506,6 +4720,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4530,6 +4745,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4542,6 +4759,8 @@ exports[`regression tests hotkey 5 selects arrow tool: [end of test] element 0 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4561,6 +4780,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4592,6 +4812,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4611,6 +4833,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4629,7 +4852,7 @@ Object { exports[`regression tests hotkey 5 selects arrow tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests hotkey 5 selects arrow tool: [end of test] number of renders 1`] = `7`; +exports[`regression tests hotkey 5 selects arrow tool: [end of test] number of renders 1`] = `8`; exports[`regression tests hotkey 6 selects line tool: [end of test] appState 1`] = ` Object { @@ -4657,6 +4880,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4681,6 +4905,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4693,6 +4919,8 @@ exports[`regression tests hotkey 6 selects line tool: [end of test] element 0 1` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4712,6 +4940,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4743,6 +4972,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4762,6 +4993,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4808,6 +5040,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4832,6 +5065,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4844,6 +5079,8 @@ exports[`regression tests hotkey 7 selects draw tool: [end of test] element 0 1` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4863,6 +5100,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4894,6 +5132,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -4913,6 +5153,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -4959,6 +5200,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -4983,6 +5225,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -4995,6 +5239,8 @@ exports[`regression tests hotkey a selects arrow tool: [end of test] element 0 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5014,6 +5260,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5045,6 +5292,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5064,6 +5313,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5082,7 +5332,7 @@ Object { exports[`regression tests hotkey a selects arrow tool: [end of test] number of elements 1`] = `1`; -exports[`regression tests hotkey a selects arrow tool: [end of test] number of renders 1`] = `7`; +exports[`regression tests hotkey a selects arrow tool: [end of test] number of renders 1`] = `8`; exports[`regression tests hotkey d selects diamond tool: [end of test] appState 1`] = ` Object { @@ -5110,6 +5360,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5134,6 +5385,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5146,6 +5399,7 @@ exports[`regression tests hotkey d selects diamond tool: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5185,6 +5439,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5239,6 +5494,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5263,6 +5519,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5275,6 +5533,7 @@ exports[`regression tests hotkey e selects ellipse tool: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5314,6 +5573,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5368,6 +5628,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5392,6 +5653,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5404,6 +5667,8 @@ exports[`regression tests hotkey l selects line tool: [end of test] element 0 1` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5423,6 +5688,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5454,6 +5720,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5473,6 +5741,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5519,6 +5788,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5543,6 +5813,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5555,6 +5827,7 @@ exports[`regression tests hotkey r selects rectangle tool: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5594,6 +5867,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5648,6 +5922,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5672,6 +5947,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5684,6 +5961,8 @@ exports[`regression tests hotkey x selects draw tool: [end of test] element 0 1` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5703,6 +5982,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5734,6 +6014,8 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -5753,6 +6035,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -5799,6 +6082,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -5834,6 +6118,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -5846,6 +6132,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -5872,6 +6159,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -5898,6 +6186,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 2 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -5924,6 +6213,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 3 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -5950,6 +6240,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 4 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -5976,6 +6267,7 @@ exports[`regression tests make a group and duplicate it: [end of test] element 5 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6017,6 +6309,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6051,6 +6344,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6072,6 +6366,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6106,6 +6401,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6127,6 +6423,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6148,6 +6445,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6185,6 +6483,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6208,6 +6507,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6231,6 +6531,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6271,6 +6572,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -6294,6 +6596,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -6317,6 +6620,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id7", @@ -6340,6 +6644,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6363,6 +6668,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6386,6 +6692,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -6442,6 +6749,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -6470,6 +6778,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -6482,6 +6792,7 @@ exports[`regression tests noop interaction after undo shouldn't create history e Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6506,6 +6817,7 @@ exports[`regression tests noop interaction after undo shouldn't create history e Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6545,6 +6857,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6579,6 +6892,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6600,6 +6914,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6654,6 +6969,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -6678,6 +6994,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": true, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -6724,6 +7042,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -6746,6 +7065,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -6792,6 +7113,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -6832,6 +7154,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -6844,6 +7168,7 @@ exports[`regression tests resize an element, trying every resize handle: [end of Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6883,6 +7208,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6918,6 +7244,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -6954,6 +7281,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6991,6 +7319,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7029,6 +7358,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7068,6 +7398,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -7108,6 +7439,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7149,6 +7481,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7191,6 +7524,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7234,6 +7568,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7278,6 +7613,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7323,6 +7659,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -7369,6 +7706,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7416,6 +7754,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7464,6 +7803,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7513,6 +7853,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -7563,6 +7904,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7617,6 +7959,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -7648,6 +7991,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -7660,6 +8005,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7699,6 +8045,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7734,6 +8081,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -7770,6 +8118,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7807,6 +8156,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -7845,6 +8195,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7884,6 +8235,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -7924,6 +8276,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -7965,6 +8318,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -8019,6 +8373,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -8048,6 +8403,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -8060,6 +8417,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8099,6 +8457,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8134,6 +8493,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8170,6 +8530,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8207,6 +8568,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -8245,6 +8607,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8284,6 +8647,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8338,6 +8702,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -8365,6 +8730,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -8377,6 +8744,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -8416,6 +8784,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8451,6 +8820,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8487,6 +8857,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8524,6 +8895,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -8578,6 +8950,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -8603,6 +8976,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -8615,6 +8990,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8654,6 +9030,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8689,6 +9066,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8743,6 +9121,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -8782,6 +9161,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -8794,6 +9175,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8833,6 +9215,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8868,6 +9251,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -8904,6 +9288,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8941,6 +9326,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -8979,6 +9365,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9018,6 +9405,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -9058,6 +9446,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9099,6 +9488,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9141,6 +9531,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9184,6 +9575,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9228,6 +9620,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9273,6 +9666,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -9319,6 +9713,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9366,6 +9761,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9414,6 +9810,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9463,6 +9860,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -9517,6 +9915,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -9554,6 +9953,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -9566,6 +9967,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9605,6 +10007,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9640,6 +10043,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -9676,6 +10080,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9713,6 +10118,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9751,6 +10157,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9790,6 +10197,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -9830,6 +10238,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9871,6 +10280,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -9913,6 +10323,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -9956,6 +10367,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10000,6 +10412,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10045,6 +10458,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10091,6 +10505,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10138,6 +10553,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10192,6 +10608,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -10227,6 +10644,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -10239,6 +10658,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10278,6 +10698,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10313,6 +10734,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10349,6 +10771,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10386,6 +10809,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10424,6 +10848,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10463,6 +10888,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10503,6 +10929,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10544,6 +10971,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10586,6 +11014,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10629,6 +11058,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10673,6 +11103,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10718,6 +11149,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10772,6 +11204,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -10805,6 +11238,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -10817,6 +11252,7 @@ exports[`regression tests resize an element, trying every resize handle: [resize Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -10856,6 +11292,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10891,6 +11328,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -10927,6 +11365,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10964,6 +11403,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11002,6 +11442,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11041,6 +11482,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -11081,6 +11523,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11122,6 +11565,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11164,6 +11608,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11207,6 +11652,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11261,6 +11707,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -11293,6 +11740,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -11305,6 +11754,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11344,6 +11794,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11379,6 +11830,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -11415,6 +11867,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11452,6 +11905,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11490,6 +11944,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11529,6 +11984,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -11569,6 +12025,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11610,6 +12067,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11652,6 +12110,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11706,6 +12165,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -11736,6 +12196,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -11748,6 +12210,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11787,6 +12250,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11822,6 +12286,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -11858,6 +12323,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11895,6 +12361,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -11933,6 +12400,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11972,6 +12440,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -12012,6 +12481,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12066,6 +12536,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -12094,6 +12565,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -12106,6 +12579,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12145,6 +12619,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12180,6 +12655,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -12216,6 +12692,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12253,6 +12730,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -12291,6 +12769,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12345,6 +12824,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -12371,6 +12851,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -12383,6 +12865,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12422,6 +12905,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12457,6 +12941,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -12493,6 +12978,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12547,6 +13033,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -12587,6 +13074,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -12599,6 +13088,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12638,6 +13128,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12673,6 +13164,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -12709,6 +13201,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12746,6 +13239,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -12784,6 +13278,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12823,6 +13318,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -12863,6 +13359,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12904,6 +13401,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -12946,6 +13444,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -12989,6 +13488,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -13033,6 +13533,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13078,6 +13579,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -13124,6 +13626,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13171,6 +13674,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -13219,6 +13723,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13268,6 +13773,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -13318,6 +13824,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13372,6 +13879,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -13410,6 +13918,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -13422,6 +13932,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13461,6 +13972,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13496,6 +14008,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -13532,6 +14045,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13569,6 +14083,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -13607,6 +14122,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13646,6 +14162,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -13686,6 +14203,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13727,6 +14245,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -13769,6 +14288,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13812,6 +14332,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -13856,6 +14377,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13901,6 +14423,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -13947,6 +14470,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -13994,6 +14518,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -14042,6 +14567,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14096,6 +14622,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -14132,6 +14659,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -14144,6 +14673,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14183,6 +14713,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14218,6 +14749,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -14254,6 +14786,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14291,6 +14824,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -14329,6 +14863,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14368,6 +14903,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -14408,6 +14944,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14449,6 +14986,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -14491,6 +15029,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14534,6 +15073,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -14578,6 +15118,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14623,6 +15164,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -14669,6 +15211,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14723,6 +15266,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -14757,6 +15301,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -14769,6 +15315,7 @@ exports[`regression tests resize an element, trying every resize handle: [unresi Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14808,6 +15355,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14843,6 +15391,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -14879,6 +15428,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14916,6 +15466,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -14954,6 +15505,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -14993,6 +15545,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 15, @@ -15033,6 +15586,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -15074,6 +15628,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -15116,6 +15671,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -15159,6 +15715,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 5, @@ -15203,6 +15760,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -15257,6 +15815,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -15281,6 +15840,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -15293,6 +15854,7 @@ exports[`regression tests selecting 'Add to library' in context menu adds elemen Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15332,6 +15894,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15386,6 +15949,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -15410,6 +15974,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -15422,6 +15988,7 @@ exports[`regression tests selecting 'Bring forward' in context menu brings eleme Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15446,6 +16013,7 @@ exports[`regression tests selecting 'Bring forward' in context menu brings eleme Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15485,6 +16053,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15519,6 +16088,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15540,6 +16110,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15574,6 +16145,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15595,6 +16167,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15649,6 +16222,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -15673,6 +16247,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -15685,6 +16261,7 @@ exports[`regression tests selecting 'Bring to front' in context menu brings elem Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15709,6 +16286,7 @@ exports[`regression tests selecting 'Bring to front' in context menu brings elem Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15748,6 +16326,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15782,6 +16361,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15803,6 +16383,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15837,6 +16418,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15858,6 +16440,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15912,6 +16495,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -15936,6 +16520,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -15948,6 +16534,7 @@ exports[`regression tests selecting 'Copy styles' in context menu copies styles: Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -15987,6 +16574,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16041,6 +16629,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -16063,6 +16652,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -16075,6 +16666,7 @@ exports[`regression tests selecting 'Delete' in context menu deletes element: [e Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16114,6 +16706,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16146,6 +16739,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16200,6 +16794,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -16224,6 +16819,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -16236,6 +16833,7 @@ exports[`regression tests selecting 'Duplicate' in context menu duplicates eleme Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16260,6 +16858,7 @@ exports[`regression tests selecting 'Duplicate' in context menu duplicates eleme Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16299,6 +16898,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16333,6 +16933,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16354,6 +16955,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16408,6 +17010,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -16438,6 +17041,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -16450,6 +17055,7 @@ exports[`regression tests selecting 'Group selection' in context menu groups sel Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -16476,6 +17082,7 @@ exports[`regression tests selecting 'Group selection' in context menu groups sel Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -16517,6 +17124,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16551,6 +17159,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16572,6 +17181,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16608,6 +17218,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -16631,6 +17242,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -16687,6 +17299,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -16711,6 +17324,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -16723,6 +17338,7 @@ exports[`regression tests selecting 'Paste styles' in context menu pastes styles Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -16747,6 +17363,7 @@ exports[`regression tests selecting 'Paste styles' in context menu pastes styles Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -16786,6 +17403,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16820,6 +17438,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16841,6 +17460,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16875,6 +17495,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16896,6 +17517,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16930,6 +17552,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16951,6 +17574,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -16985,6 +17609,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17006,6 +17631,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17040,6 +17666,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17061,6 +17688,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17095,6 +17723,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17116,6 +17745,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17150,6 +17780,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17171,6 +17802,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17205,6 +17837,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17226,6 +17859,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17260,6 +17894,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17281,6 +17916,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17315,6 +17951,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17336,6 +17973,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17370,6 +18008,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17391,6 +18030,7 @@ Object { Object { "angle": 0, "backgroundColor": "#e64980", + "boundElementIds": null, "fillStyle": "cross-hatch", "groupIds": Array [], "height": 20, @@ -17445,6 +18085,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -17469,6 +18110,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -17481,6 +18124,7 @@ exports[`regression tests selecting 'Send backward' in context menu sends elemen Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17505,6 +18149,7 @@ exports[`regression tests selecting 'Send backward' in context menu sends elemen Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17544,6 +18189,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17578,6 +18224,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17599,6 +18246,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17633,6 +18281,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17654,6 +18303,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17708,6 +18358,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -17732,6 +18383,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -17744,6 +18397,7 @@ exports[`regression tests selecting 'Send to back' in context menu sends element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17768,6 +18422,7 @@ exports[`regression tests selecting 'Send to back' in context menu sends element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17807,6 +18462,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17841,6 +18497,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17862,6 +18519,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17896,6 +18554,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17917,6 +18576,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -17971,6 +18631,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -17999,6 +18660,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -18011,6 +18674,7 @@ exports[`regression tests selecting 'Ungroup selection' in context menu ungroups Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18035,6 +18699,7 @@ exports[`regression tests selecting 'Ungroup selection' in context menu ungroups Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18074,6 +18739,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18108,6 +18774,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18129,6 +18796,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18165,6 +18833,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -18188,6 +18857,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -18226,6 +18896,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18247,6 +18918,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -18301,6 +18973,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -18334,6 +19007,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -18346,6 +19021,7 @@ exports[`regression tests shift-click to multiselect, then drag: [end of test] e Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18370,6 +19046,7 @@ exports[`regression tests shift-click to multiselect, then drag: [end of test] e Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18409,6 +19086,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18443,6 +19121,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18464,6 +19143,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18502,6 +19182,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18523,6 +19204,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18577,6 +19259,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -18607,6 +19290,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -18619,6 +19304,7 @@ exports[`regression tests shows 'Group selection' in context menu for multiple s Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18643,6 +19329,7 @@ exports[`regression tests shows 'Group selection' in context menu for multiple s Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18682,6 +19369,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18716,6 +19404,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18737,6 +19426,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18791,6 +19481,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -18823,6 +19514,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -18835,6 +19528,7 @@ exports[`regression tests shows 'Ungroup selection' in context menu for group in Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -18861,6 +19555,7 @@ exports[`regression tests shows 'Ungroup selection' in context menu for group in Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -18902,6 +19597,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18936,6 +19632,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18957,6 +19654,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -18994,6 +19692,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -19017,6 +19716,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id4", @@ -19073,6 +19773,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -19095,6 +19796,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -19141,6 +19844,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -19165,6 +19869,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -19177,6 +19883,7 @@ exports[`regression tests shows context menu for element: [end of test] element Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -19216,6 +19923,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, @@ -19270,6 +19978,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -19292,6 +20001,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -19338,6 +20049,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -19362,6 +20074,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -19374,6 +20088,7 @@ exports[`regression tests supports nested groups: [end of test] element 0 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19400,6 +20115,7 @@ exports[`regression tests supports nested groups: [end of test] element 1 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19427,6 +20143,7 @@ exports[`regression tests supports nested groups: [end of test] element 2 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19469,6 +20186,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19503,6 +20221,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19524,6 +20243,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19558,6 +20278,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19579,6 +20300,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19600,6 +20322,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -19636,6 +20359,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19659,6 +20383,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19682,6 +20407,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19720,6 +20446,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19743,6 +20470,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19766,6 +20494,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19804,6 +20533,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19827,6 +20557,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19851,6 +20582,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19891,6 +20623,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id3", @@ -19914,6 +20647,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19938,6 +20672,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ "id5", @@ -19995,6 +20730,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -20019,6 +20755,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": true, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -20065,6 +20803,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -20089,6 +20828,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -20101,6 +20842,7 @@ exports[`regression tests undo/redo drawing an element: [end of test] element 0 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -20115,9 +20857,9 @@ Object { "type": "rectangle", "version": 6, "versionNonce": 1006504105, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, } `; @@ -20125,9 +20867,10 @@ exports[`regression tests undo/redo drawing an element: [end of test] element 1 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], - "height": 10, + "height": 20, "id": "id1", "isDeleted": false, "opacity": 100, @@ -20139,9 +20882,9 @@ Object { "type": "rectangle", "version": 6, "versionNonce": 289600103, - "width": 10, - "x": 30, - "y": 10, + "width": 30, + "x": 40, + "y": 0, } `; @@ -20149,13 +20892,15 @@ exports[`regression tests undo/redo drawing an element: [end of test] element 2 Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, "id": "id2", "isDeleted": true, "lastCommittedPoint": Array [ - 10, + 60, 10, ], "opacity": 100, @@ -20165,20 +20910,21 @@ Object { 0, ], Array [ - 10, + 60, 10, ], ], "roughness": 1, "seed": 401146281, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 11, "versionNonce": 1315507081, - "width": 10, - "x": 50, + "width": 60, + "x": 130, "y": 10, } `; @@ -20201,6 +20947,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -20215,16 +20962,17 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], - "height": 10, + "height": 20, "id": "id1", "isDeleted": false, "opacity": 100, @@ -20236,20 +20984,22 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 30, + "x": 40, + "y": 0, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 20, "id": "id2", "isDeleted": false, "lastCommittedPoint": Array [ - 0, + 100, 20, ], "opacity": 100, @@ -20259,24 +21009,25 @@ Object { 0, ], Array [ - 10, + 60, 10, ], Array [ - 0, + 100, 20, ], ], "roughness": 1, "seed": 401146281, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 7, "versionNonce": 400692809, - "width": 10, - "x": 50, + "width": 100, + "x": 130, "y": 10, }, ], @@ -20295,6 +21046,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -20309,16 +21061,17 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], - "height": 10, + "height": 20, "id": "id1", "isDeleted": false, "opacity": 100, @@ -20330,20 +21083,22 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 30, + "x": 40, + "y": 0, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, "id": "id2", "isDeleted": false, "lastCommittedPoint": Array [ - 10, + 60, 10, ], "opacity": 100, @@ -20353,20 +21108,21 @@ Object { 0, ], Array [ - 10, + 60, 10, ], ], "roughness": 1, "seed": 401146281, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, "type": "arrow", "version": 5, "versionNonce": 1014066025, - "width": 10, - "x": 50, + "width": 60, + "x": 130, "y": 10, }, ], @@ -20387,6 +21143,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -20401,9 +21158,9 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, ], }, @@ -20421,6 +21178,7 @@ Object { Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -20435,16 +21193,17 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 1278240551, - "width": 10, + "width": 20, "x": 10, - "y": 10, + "y": -10, }, Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], - "height": 10, + "height": 20, "id": "id1", "isDeleted": false, "opacity": 100, @@ -20456,9 +21215,9 @@ Object { "type": "rectangle", "version": 2, "versionNonce": 453191, - "width": 10, - "x": 30, - "y": 10, + "width": 30, + "x": 40, + "y": 0, }, ], }, @@ -20468,7 +21227,7 @@ Object { exports[`regression tests undo/redo drawing an element: [end of test] number of elements 1`] = `3`; -exports[`regression tests undo/redo drawing an element: [end of test] number of renders 1`] = `25`; +exports[`regression tests undo/redo drawing an element: [end of test] number of renders 1`] = `28`; exports[`regression tests updates fontSize & fontFamily appState: [end of test] appState 1`] = ` Object { @@ -20496,6 +21255,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -20518,6 +21278,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, @@ -20575,6 +21337,7 @@ Object { "exportBackground": true, "gridSize": null, "height": 768, + "isBindingEnabled": true, "isCollaborating": false, "isLibraryOpen": false, "isLoading": false, @@ -20597,6 +21360,8 @@ Object { "shouldAddWatermark": false, "shouldCacheIgnoreZoom": false, "showShortcutsDialog": false, + "startBoundElement": null, + "suggestedBindings": Array [], "username": "", "viewBackgroundColor": "#ffffff", "width": 1024, diff --git a/src/tests/__snapshots__/resize.test.tsx.snap b/src/tests/__snapshots__/resize.test.tsx.snap index 390090b6..95d62327 100644 --- a/src/tests/__snapshots__/resize.test.tsx.snap +++ b/src/tests/__snapshots__/resize.test.tsx.snap @@ -4,6 +4,7 @@ exports[`resize element rectangle 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -28,6 +29,7 @@ exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] = Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, diff --git a/src/tests/__snapshots__/selection.test.tsx.snap b/src/tests/__snapshots__/selection.test.tsx.snap index 018a17ab..90c2a572 100644 --- a/src/tests/__snapshots__/selection.test.tsx.snap +++ b/src/tests/__snapshots__/selection.test.tsx.snap @@ -4,6 +4,8 @@ exports[`select single element on the scene arrow 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -23,6 +25,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -39,6 +42,8 @@ exports[`select single element on the scene arrow escape 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -58,6 +63,7 @@ Object { ], "roughness": 1, "seed": 337897, + "startBinding": null, "strokeColor": "#000000", "strokeStyle": "solid", "strokeWidth": 1, @@ -74,6 +80,7 @@ exports[`select single element on the scene diamond 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -98,6 +105,7 @@ exports[`select single element on the scene ellipse 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, @@ -122,6 +130,7 @@ exports[`select single element on the scene rectangle 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", + "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [], "height": 50, diff --git a/src/tests/geometricAlgebra.test.ts b/src/tests/geometricAlgebra.test.ts new file mode 100644 index 00000000..59279073 --- /dev/null +++ b/src/tests/geometricAlgebra.test.ts @@ -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))); + }); + }); +}); diff --git a/src/tests/move.test.tsx b/src/tests/move.test.tsx index 9e7a2ff4..8279baa1 100644 --- a/src/tests/move.test.tsx +++ b/src/tests/move.test.tsx @@ -83,7 +83,9 @@ describe("duplicate element on move when ALT is clicked", () => { fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 }); fireEvent.pointerUp(canvas); - expect(renderScene).toHaveBeenCalledTimes(4); + // TODO: This used to be 4, but binding made it go up to 5. Do we need + // that additional render? + expect(renderScene).toHaveBeenCalledTimes(5); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(2); diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 6650e603..3d49dca5 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -259,40 +259,40 @@ afterEach(() => { describe("regression tests", () => { it("draw every type of shape", () => { clickTool("rectangle"); - mouse.down(10, 10); - mouse.up(10, 10); + mouse.down(10, -10); + mouse.up(20, 10); clickTool("diamond"); mouse.down(10, -10); - mouse.up(10, 10); + mouse.up(20, 10); clickTool("ellipse"); mouse.down(10, -10); - mouse.up(10, 10); + mouse.up(20, 10); clickTool("arrow"); - mouse.down(10, -10); - mouse.up(10, 10); + mouse.down(40, -10); + mouse.up(50, 10); clickTool("line"); - mouse.down(10, -10); - mouse.up(10, 10); + mouse.down(40, -10); + mouse.up(50, 10); clickTool("arrow"); - mouse.click(10, -10); - mouse.click(10, 10); - mouse.click(-10, 10); + mouse.click(40, -10); + mouse.click(50, 10); + mouse.click(30, 10); hotkeyPress("ENTER"); clickTool("line"); - mouse.click(10, -20); - mouse.click(10, 10); - mouse.click(-10, 10); + mouse.click(40, -20); + mouse.click(50, 10); + mouse.click(30, 10); hotkeyPress("ENTER"); clickTool("draw"); - mouse.down(10, -20); - mouse.up(10, 10); + mouse.down(40, -20); + mouse.up(50, 10); expect(h.elements.map((element) => element.type)).toEqual([ "rectangle", @@ -569,17 +569,17 @@ describe("regression tests", () => { it("undo/redo drawing an element", () => { clickTool("rectangle"); - mouse.down(10, 10); - mouse.up(10, 10); + mouse.down(10, -10); + mouse.up(20, 10); clickTool("rectangle"); - mouse.down(10, -10); - mouse.up(10, 10); + mouse.down(10, 0); + mouse.up(30, 20); clickTool("arrow"); - mouse.click(10, -10); - mouse.click(10, 10); - mouse.click(-10, 10); + mouse.click(60, -10); + mouse.click(60, 10); + mouse.click(40, 10); hotkeyPress("ENTER"); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3); diff --git a/src/types.ts b/src/types.ts index c9c6fa26..d9f60ab5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,11 +7,13 @@ import { ExcalidrawElement, FontFamily, GroupId, + ExcalidrawBindableElement, } from "./element/types"; import { SHAPES } from "./shapes"; import { Point as RoughPoint } from "roughjs/bin/geometry"; import { SocketUpdateDataSource } from "./data"; import { LinearElementEditor } from "./element/linearElementEditor"; +import { SuggestedBinding } from "./element/binding"; export type FlooredNumber = number & { _brand: "FlooredNumber" }; export type Point = Readonly; @@ -33,6 +35,9 @@ export type AppState = { resizingElement: NonDeletedExcalidrawElement | null; multiElement: NonDeleted | null; selectionElement: NonDeletedExcalidrawElement | null; + isBindingEnabled: boolean; + startBoundElement: NonDeleted | null; + suggestedBindings: SuggestedBinding[]; // element being edited, but not necessarily added to elements array yet // (e.g. text element when typing into the input) editingElement: NonDeletedExcalidrawElement | null; diff --git a/src/utils.ts b/src/utils.ts index 865652d1..3ad98b5b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -241,7 +241,7 @@ export const isRTL = (text: string) => { }; export function tupleToCoors( - xyTuple: [number, number], + xyTuple: readonly [number, number], ): { x: number; y: number } { const [x, y] = xyTuple; return { x, y };