diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 311d889707..2916345d04 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -5,19 +5,25 @@ import { t } from "../i18n"; import { register } from "./register"; import { getNonDeletedElements } from "../element"; import type { ExcalidrawElement } from "../element/types"; -import type { AppState } from "../types"; -import { newElementWith } from "../element/mutateElement"; +import type { AppClassProperties, AppState } from "../types"; +import { mutateElement, newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; import { LinearElementEditor } from "../element/linearElementEditor"; import { fixBindingsAfterDeletion } from "../element/binding"; -import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; +import { + isBoundToContainer, + isElbowArrow, + isFrameLikeElement, +} from "../element/typeChecks"; import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; import { StoreAction } from "../store"; +import { mutateElbowArrow } from "../element/routing"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], appState: AppState, + app: AppClassProperties, ) => { const framesToBeDeleted = new Set( getSelectedElements( @@ -29,6 +35,26 @@ const deleteSelectedElements = ( return { elements: elements.map((el) => { if (appState.selectedElementIds[el.id]) { + if (el.boundElements) { + el.boundElements.forEach((candidate) => { + const bound = app.scene + .getNonDeletedElementsMap() + .get(candidate.id); + if (bound && isElbowArrow(bound)) { + mutateElement(bound, { + startBinding: + el.id === bound.startBinding?.elementId + ? null + : bound.startBinding, + endBinding: + el.id === bound.endBinding?.elementId + ? null + : bound.endBinding, + }); + mutateElbowArrow(bound, app.scene, bound.points); + } + }); + } return newElementWith(el, { isDeleted: true }); } @@ -130,7 +156,11 @@ export const actionDeleteSelected = register({ : endBindingElement, }; - LinearElementEditor.deletePoints(element, selectedPointsIndices); + LinearElementEditor.deletePoints( + element, + selectedPointsIndices, + app.scene, + ); return { elements, @@ -149,7 +179,7 @@ export const actionDeleteSelected = register({ }; } let { elements: nextElements, appState: nextAppState } = - deleteSelectedElements(elements, appState); + deleteSelectedElements(elements, appState, app); fixBindingsAfterDeletion( nextElements, elements.filter(({ id }) => appState.selectedElementIds[id]), diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 44c26e226a..9d72fdc8d1 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -40,12 +40,11 @@ export const actionDuplicateSelection = register({ icon: DuplicateIcon, trackEvent: { category: "element" }, perform: (elements, appState, formData, app) => { - const elementsMap = app.scene.getNonDeletedElementsMap(); // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { const ret = LinearElementEditor.duplicateSelectedPoints( appState, - elementsMap, + app.scene, ); if (!ret) { diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 15956b3a39..39dd2363a2 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -38,6 +38,7 @@ export const actionFinalize = register({ startBindingElement, endBindingElement, elementsMap, + scene, ); } return { @@ -136,6 +137,7 @@ export const actionFinalize = register({ appState, { x, y }, elementsMap, + elements, ); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 3f521d27f8..128be86c9d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -120,11 +120,14 @@ const flipElements = ( true, flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? minY : maxY, + app.scene, ); bindOrUnbindLinearElements( selectedElements.filter(isLinearElement), elementsMap, + app.scene.getNonDeletedElements(), + app.scene, isBindingEnabled(appState), [], ); diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 8e2d10454f..8eaf3b6ef7 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -50,12 +50,13 @@ export const createUndoAction: ActionCreator = (history, store) => ({ icon: UndoIcon, trackEvent: { category: "history" }, viewMode: false, - perform: (elements, appState) => + perform: (elements, appState, value, app) => writeData(appState, () => history.undo( arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` appState, store.snapshot, + app.scene, ), ), keyTest: (event) => @@ -91,12 +92,13 @@ export const createRedoAction: ActionCreator = (history, store) => ({ icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, - perform: (elements, appState) => + perform: (elements, appState, _, app) => writeData(appState, () => history.redo( arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` appState, store.snapshot, + app.scene, ), ), keyTest: (event) => diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 12f00c2483..acde9b1e52 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -1,6 +1,6 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { isLinearElement } from "../element/typeChecks"; +import { isElbowArrow, isLinearElement } from "../element/typeChecks"; import type { ExcalidrawLinearElement } from "../element/types"; import { StoreAction } from "../store"; import { register } from "./register"; @@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({ if ( !appState.editingLinearElement && selectedElements.length === 1 && - isLinearElement(selectedElements[0]) + isLinearElement(selectedElements[0]) && + !isElbowArrow(selectedElements[0]) ) { return true; } diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index e0cc825c90..add2e34e3b 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { AppClassProperties, AppState, Primitive } from "../types"; +import type { AppClassProperties, AppState, Point, Primitive } from "../types"; import type { StoreActionType } from "../store"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, @@ -50,8 +50,12 @@ import { ArrowheadDiamondIcon, ArrowheadDiamondOutlineIcon, fontSizeIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, } from "../components/icons"; import { + ARROW_TYPE, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, @@ -67,12 +71,15 @@ import { import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement } from "../element/textElement"; import { + isArrowElement, isBoundToContainer, + isElbowArrow, isLinearElement, isUsingAdaptiveRadius, } from "../element/typeChecks"; import type { Arrowhead, + ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, @@ -91,10 +98,23 @@ import { isSomeElementSelected, } from "../scene"; import { hasStrokeColor } from "../scene/comparisons"; -import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils"; +import { + arrayToMap, + getFontFamilyString, + getShortcutKey, + tupleToCoors, +} from "../utils"; import { register } from "./register"; import { StoreAction } from "../store"; import { Fonts, getLineHeight } from "../fonts"; +import { + bindLinearElement, + bindPointToSnapToElementOutline, + calculateFixedPointForElbowArrowBinding, + getHoveredElementForBinding, +} from "../element/binding"; +import { mutateElbowArrow } from "../element/routing"; +import { LinearElementEditor } from "../element/linearElementEditor"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({ trackEvent: false, perform: (elements, appState, value) => { return { - elements: changeProperty(elements, appState, (el) => - newElementWith(el, { + elements: changeProperty(elements, appState, (el) => { + if (isElbowArrow(el)) { + return el; + } + + return newElementWith(el, { roundness: value === "round" ? { @@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({ : ROUNDNESS.PROPORTIONAL_RADIUS, } : null, - }), - ), + }); + }), appState: { ...appState, currentItemRoundness: value, @@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({ appState, (element) => hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", - (element) => element.hasOwnProperty("roundness"), + (element) => + !isArrowElement(element) && element.hasOwnProperty("roundness"), (hasSelection) => hasSelection ? null : appState.currentItemRoundness, )} @@ -1518,3 +1543,219 @@ export const actionChangeArrowhead = register({ ); }, }); + +export const actionChangeArrowType = register({ + name: "changeArrowType", + label: "Change arrow types", + trackEvent: false, + perform: (elements, appState, value, app) => { + return { + elements: changeProperty(elements, appState, (el) => { + if (!isArrowElement(el)) { + return el; + } + const newElement = newElementWith(el, { + roundness: + value === ARROW_TYPE.round + ? { + type: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + elbowed: value === ARROW_TYPE.elbow, + points: + value === ARROW_TYPE.elbow || el.elbowed + ? [el.points[0], el.points[el.points.length - 1]] + : el.points, + }); + + if (isElbowArrow(newElement)) { + const elementsMap = app.scene.getNonDeletedElementsMap(); + + app.dismissLinearEditor(); + + const startGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + const endGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + -1, + elementsMap, + ); + const startHoveredElement = + !newElement.startBinding && + getHoveredElementForBinding( + tupleToCoors(startGlobalPoint), + elements, + elementsMap, + true, + ); + const endHoveredElement = + !newElement.endBinding && + getHoveredElementForBinding( + tupleToCoors(endGlobalPoint), + elements, + elementsMap, + true, + ); + const startElement = startHoveredElement + ? startHoveredElement + : newElement.startBinding && + (elementsMap.get( + newElement.startBinding.elementId, + ) as ExcalidrawBindableElement); + const endElement = endHoveredElement + ? endHoveredElement + : newElement.endBinding && + (elementsMap.get( + newElement.endBinding.elementId, + ) as ExcalidrawBindableElement); + + const finalStartPoint = startHoveredElement + ? bindPointToSnapToElementOutline( + startGlobalPoint, + endGlobalPoint, + startHoveredElement, + elementsMap, + ) + : startGlobalPoint; + const finalEndPoint = endHoveredElement + ? bindPointToSnapToElementOutline( + endGlobalPoint, + startGlobalPoint, + endHoveredElement, + elementsMap, + ) + : endGlobalPoint; + + startHoveredElement && + bindLinearElement( + newElement, + startHoveredElement, + "start", + elementsMap, + ); + endHoveredElement && + bindLinearElement( + newElement, + endHoveredElement, + "end", + elementsMap, + ); + + mutateElbowArrow( + newElement, + app.scene, + [finalStartPoint, finalEndPoint].map( + (point) => + [point[0] - newElement.x, point[1] - newElement.y] as Point, + ), + [0, 0], + { + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }, + ); + } else { + mutateElement( + newElement, + { + startBinding: newElement.startBinding + ? { ...newElement.startBinding, fixedPoint: null } + : null, + endBinding: newElement.endBinding + ? { ...newElement.endBinding, fixedPoint: null } + : null, + }, + false, + ); + } + + return newElement; + }), + appState: { + ...appState, + currentItemArrowType: value, + }, + storeAction: StoreAction.CAPTURE, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + return ( +
+ {t("labels.arrowtypes")} + { + if (isArrowElement(element)) { + return element.elbowed + ? ARROW_TYPE.elbow + : element.roundness + ? ARROW_TYPE.round + : ARROW_TYPE.sharp; + } + + return null; + }, + (element) => isArrowElement(element), + (hasSelection) => + hasSelection ? null : appState.currentItemArrowType, + )} + onChange={(value) => updateData(value)} + /> +
+ ); + }, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 6597ec0f07..2d0275bb3a 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -70,6 +70,7 @@ export type ActionName = | "changeSloppiness" | "changeStrokeStyle" | "changeArrowhead" + | "changeArrowType" | "changeOpacity" | "changeFontSize" | "toggleCanvasMenu" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 2e490a9086..9c7c43e28a 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -1,5 +1,6 @@ import { COLOR_PALETTE } from "./colors"; import { + ARROW_TYPE, DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, @@ -33,6 +34,7 @@ export const getDefaultAppState = (): Omit< currentItemStartArrowhead: null, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemRoundness: "round", + currentItemArrowType: ARROW_TYPE.round, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, @@ -143,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (< export: false, server: false, }, + currentItemArrowType: { + browser: true, + export: false, + server: false, + }, currentItemOpacity: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/binaryheap.ts b/packages/excalidraw/binaryheap.ts new file mode 100644 index 0000000000..0bacadceb8 --- /dev/null +++ b/packages/excalidraw/binaryheap.ts @@ -0,0 +1,105 @@ +export default class BinaryHeap { + private content: T[] = []; + + constructor(private scoreFunction: (node: T) => number) {} + + sinkDown(idx: number) { + const node = this.content[idx]; + while (idx > 0) { + const parentN = ((idx + 1) >> 1) - 1; + const parent = this.content[parentN]; + if (this.scoreFunction(node) < this.scoreFunction(parent)) { + this.content[parentN] = node; + this.content[idx] = parent; + idx = parentN; // TODO: Optimize + } else { + break; + } + } + } + + bubbleUp(idx: number) { + const length = this.content.length; + const node = this.content[idx]; + const score = this.scoreFunction(node); + + while (true) { + const child2N = (idx + 1) << 1; + const child1N = child2N - 1; + let swap = null; + let child1Score = 0; + + if (child1N < length) { + const child1 = this.content[child1N]; + child1Score = this.scoreFunction(child1); + if (child1Score < score) { + swap = child1N; + } + } + + if (child2N < length) { + const child2 = this.content[child2N]; + const child2Score = this.scoreFunction(child2); + if (child2Score < (swap === null ? score : child1Score)) { + swap = child2N; + } + } + + if (swap !== null) { + this.content[idx] = this.content[swap]; + this.content[swap] = node; + idx = swap; // TODO: Optimize + } else { + break; + } + } + } + + push(node: T) { + this.content.push(node); + this.sinkDown(this.content.length - 1); + } + + pop(): T | null { + if (this.content.length === 0) { + return null; + } + + const result = this.content[0]; + const end = this.content.pop()!; + + if (this.content.length > 0) { + this.content[0] = end; + this.bubbleUp(0); + } + + return result; + } + + remove(node: T) { + if (this.content.length === 0) { + return; + } + + const i = this.content.indexOf(node); + const end = this.content.pop()!; + + if (i < this.content.length) { + this.content[i] = end; + + if (this.scoreFunction(end) < this.scoreFunction(node)) { + this.sinkDown(i); + } else { + this.bubbleUp(i); + } + } + } + + size(): number { + return this.content.length; + } + + rescoreElement(node: T) { + this.sinkDown(this.content.indexOf(node)); + } +} diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts index 884235e800..0d07157c7c 100644 --- a/packages/excalidraw/change.ts +++ b/packages/excalidraw/change.ts @@ -29,6 +29,7 @@ import type { } from "./element/types"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { getNonDeletedGroupIds } from "./groups"; +import type Scene from "./scene/Scene"; import { getObservedAppState } from "./store"; import type { AppState, @@ -1053,6 +1054,7 @@ export class ElementsChange implements Change { public applyTo( elements: SceneElementsMap, snapshot: Map, + scene: Scene, ): [SceneElementsMap, boolean] { let nextElements = toBrandedType(new Map(elements)); let changedElements: Map; @@ -1100,7 +1102,7 @@ export class ElementsChange implements Change { try { // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); - ElementsChange.redrawBoundArrows(nextElements, changedElements); + ElementsChange.redrawBoundArrows(nextElements, changedElements, scene); // the following reorder performs also mutations, but only on new instances of changed elements // (unless something goes really bad and it fallbacks to fixing all invalid indices) @@ -1457,10 +1459,13 @@ export class ElementsChange implements Change { private static redrawBoundArrows( elements: SceneElementsMap, changed: Map, + scene: Scene, ) { for (const element of changed.values()) { if (!element.isDeleted && isBindableElement(element)) { - updateBoundElements(element, elements); + updateBoundElements(element, elements, scene, { + changedElements: changed, + }); } } } diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 62fe938860..2a80a4ba7f 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -257,8 +257,6 @@ const chartLines = ( type: "line", x, y, - startArrowhead: null, - endArrowhead: null, width: chartWidth, points: [ [0, 0], @@ -273,8 +271,6 @@ const chartLines = ( type: "line", x, y, - startArrowhead: null, - endArrowhead: null, height: chartHeight, points: [ [0, 0], @@ -289,8 +285,6 @@ const chartLines = ( type: "line", x, y: y - BAR_HEIGHT - BAR_GAP, - startArrowhead: null, - endArrowhead: null, strokeStyle: "dotted", width: chartWidth, opacity: GRID_OPACITY, @@ -418,8 +412,6 @@ const chartTypeLine = ( type: "line", x: x + BAR_GAP + BAR_WIDTH / 2, y: y - BAR_GAP, - startArrowhead: null, - endArrowhead: null, height: maxY - minY, width: maxX - minX, strokeWidth: 2, @@ -453,8 +445,6 @@ const chartTypeLine = ( type: "line", x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, y: y - cy, - startArrowhead: null, - endArrowhead: null, height: cy, strokeStyle: "dotted", opacity: GRID_OPACITY, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 2be642f791..91102aef2c 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; import { capitalizeString, isTransparent } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; -import { hasStrokeColor } from "../scene/comparisons"; +import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; import { trackEvent } from "../analytics"; import { hasBoundTextElement, + isElbowArrow, isLinearElement, isTextElement, } from "../element/typeChecks"; @@ -121,7 +122,8 @@ export const SelectedShapeActions = ({ const showLineEditorAction = !appState.editingLinearElement && targetElements.length === 1 && - isLinearElement(targetElements[0]); + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); return (
@@ -155,6 +157,11 @@ export const SelectedShapeActions = ({ <>{renderAction("changeRoundness")} )} + {(toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type))) && ( + <>{renderAction("changeArrowType")} + )} + {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f54ae3ac4c..8a4017429e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -48,7 +48,7 @@ import { } from "../appState"; import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; -import type { EXPORT_IMAGE_TYPES } from "../constants"; +import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -142,6 +142,7 @@ import { newEmbeddableElement, newMagicFrameElement, newIframeElement, + newArrowElement, } from "../element/newElement"; import { hasBoundTextElement, @@ -160,6 +161,7 @@ import { isIframeLikeElement, isMagicFrameElement, isTextBindableContainer, + isElbowArrow, } from "../element/typeChecks"; import type { ExcalidrawBindableElement, @@ -181,6 +183,7 @@ import type { ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, + ExcalidrawArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -425,6 +428,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; +import { mutateElbowArrow } from "../element/routing"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -2112,6 +2116,14 @@ class App extends React.Component { }); }; + public dismissLinearEditor = () => { + setTimeout(() => { + this.setState({ + editingLinearElement: null, + }); + }); + }; + public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => { if (this.unmounted || actionResult === false) { return; @@ -2803,6 +2815,7 @@ class App extends React.Component { ), ), this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), ); } @@ -3947,14 +3960,27 @@ class App extends React.Component { } if (isArrowKey(event.key)) { - const step = - (this.state.gridSize && + const selectedElements = this.scene.getSelectedElements({ + selectedElementIds: this.state.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + const elbowArrow = selectedElements.find(isElbowArrow) as + | ExcalidrawArrowElement + | undefined; + + const step = elbowArrow + ? elbowArrow.startBinding || elbowArrow.endBinding + ? 0 + : ELEMENT_TRANSLATE_AMOUNT + : (this.state.gridSize && + (event.shiftKey + ? ELEMENT_TRANSLATE_AMOUNT + : this.state.gridSize)) || (event.shiftKey - ? ELEMENT_TRANSLATE_AMOUNT - : this.state.gridSize)) || - (event.shiftKey - ? ELEMENT_SHIFT_TRANSLATE_AMOUNT - : ELEMENT_TRANSLATE_AMOUNT); + ? ELEMENT_SHIFT_TRANSLATE_AMOUNT + : ELEMENT_TRANSLATE_AMOUNT); let offsetX = 0; let offsetY = 0; @@ -3969,26 +3995,27 @@ class App extends React.Component { offsetY = step; } - const selectedElements = this.scene.getSelectedElements({ - selectedElementIds: this.state.selectedElementIds, - includeBoundTextElement: true, - includeElementsInFrames: true, - }); - selectedElements.forEach((element) => { mutateElement(element, { x: element.x + offsetX, y: element.y + offsetY, }); - updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { - simultaneouslyUpdated: selectedElements, - }); + updateBoundElements( + element, + this.scene.getNonDeletedElementsMap(), + this.scene, + { + simultaneouslyUpdated: selectedElements, + }, + ); }); this.setState({ suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, + selectedElements.filter( + (element) => element.id !== elbowArrow?.id || step !== 0, + ), this.scene.getNonDeletedElementsMap(), ), }); @@ -4006,11 +4033,13 @@ class App extends React.Component { selectedElements[0].id ) { this.store.shouldCaptureIncrement(); - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElement, - ), - }); + if (!isElbowArrow(selectedElement)) { + this.setState({ + editingLinearElement: new LinearElementEditor( + selectedElement, + ), + }); + } } } } else if ( @@ -4058,6 +4087,16 @@ class App extends React.Component { })`, ); } + if (shape === "arrow" && this.state.activeTool.type === "arrow") { + this.setState((prevState) => ({ + currentItemArrowType: + prevState.currentItemArrowType === ARROW_TYPE.sharp + ? ARROW_TYPE.round + : prevState.currentItemArrowType === ARROW_TYPE.round + ? ARROW_TYPE.elbow + : ARROW_TYPE.sharp, + })); + } this.setActiveTool({ type: shape }); event.stopPropagation(); } else if (event.key === KEYS.Q) { @@ -4191,6 +4230,8 @@ class App extends React.Component { bindOrUnbindLinearElements( this.scene.getSelectedElements(this.state).filter(isLinearElement), this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), + this.scene, isBindingEnabled(this.state), this.state.selectedLinearElement?.selectedPointsIndices ?? [], ); @@ -4422,7 +4463,7 @@ class App extends React.Component { onChange: withBatchedUpdates((nextOriginalText) => { updateElement(nextOriginalText, false); if (isNonDeletedElement(element)) { - updateBoundElements(element, elementsMap); + updateBoundElements(element, elementsMap, this.scene); } }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { @@ -4871,7 +4912,9 @@ class App extends React.Component { if ( event[KEYS.CTRL_OR_CMD] && (!this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElements[0].id) + this.state.editingLinearElement.elementId !== + selectedElements[0].id) && + !isElbowArrow(selectedElements[0]) ) { this.store.shouldCaptureIncrement(); this.setState({ @@ -5214,7 +5257,7 @@ class App extends React.Component { scenePointerX, scenePointerY, this.state, - this.scene.getNonDeletedElementsMap(), + this.scene, ); if ( @@ -5301,7 +5344,9 @@ class App extends React.Component { const [gridX, gridY] = getGridPoint( scenePointerX, scenePointerY, - event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement) + ? null + : this.state.gridSize, ); const [lastCommittedX, lastCommittedY] = @@ -5325,16 +5370,35 @@ class App extends React.Component { if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - // update last uncommitted point - mutateElement(multiElement, { - points: [ - ...points.slice(0, -1), + if (isElbowArrow(multiElement)) { + mutateElbowArrow( + multiElement, + this.scene, [ - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, + ...points.slice(0, -1), + [ + lastCommittedX + dxFromLastCommitted, + lastCommittedY + dyFromLastCommitted, + ], ], - ], - }); + undefined, + undefined, + { + isDragging: true, + }, + ); + } else { + // update last uncommitted point + mutateElement(multiElement, { + points: [ + ...points.slice(0, -1), + [ + lastCommittedX + dxFromLastCommitted, + lastCommittedY + dyFromLastCommitted, + ], + ], + }); + } } return; @@ -5369,8 +5433,9 @@ class App extends React.Component { } if ( - !this.state.selectedLinearElement || - this.state.selectedLinearElement.hoverPointIndex === -1 + (!this.state.selectedLinearElement || + this.state.selectedLinearElement.hoverPointIndex === -1) && + !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -5658,7 +5723,12 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + !isElbowArrow(element) || + !(element.startBinding || element.endBinding) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } if ( @@ -6232,6 +6302,7 @@ class App extends React.Component { const origin = viewportCoordsToSceneCoords(event, this.state); const selectedElements = this.scene.getSelectedElements(this.state); const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements); + const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0; return { origin, @@ -6240,7 +6311,9 @@ class App extends React.Component { getGridPoint( origin.x, origin.y, - event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, + event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly + ? null + : this.state.gridSize, ), ), scrollbars: isOverScrollBars( @@ -6421,7 +6494,7 @@ class App extends React.Component { this.store, pointerDownState.origin, linearElementEditor, - this, + this.scene, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6753,6 +6826,7 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); this.scene.insertElement(element); @@ -6923,6 +6997,17 @@ class App extends React.Component { return; } + // Elbow arrows cannot be created by putting down points + // only the start and end points can be defined + if (isElbowArrow(multiElement) && multiElement.points.length > 1) { + mutateElement(multiElement, { + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + }); + this.actionManager.executeAction(actionFinalize); + return; + } + const { x: rx, y: ry, lastCommittedPoint } = multiElement; // clicking inside commit zone → finalize arrow @@ -6978,26 +7063,50 @@ class App extends React.Component { ? [currentItemStartArrowhead, currentItemEndArrowhead] : [null, null]; - const element = newLinearElement({ - type: elementType, - x: gridX, - y: gridY, - strokeColor: this.state.currentItemStrokeColor, - backgroundColor: this.state.currentItemBackgroundColor, - fillStyle: this.state.currentItemFillStyle, - strokeWidth: this.state.currentItemStrokeWidth, - strokeStyle: this.state.currentItemStrokeStyle, - roughness: this.state.currentItemRoughness, - opacity: this.state.currentItemOpacity, - roundness: - this.state.currentItemRoundness === "round" - ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } - : null, - startArrowhead, - endArrowhead, - locked: false, - frameId: topLayerFrame ? topLayerFrame.id : null, - }); + const element = + elementType === "arrow" + ? newArrowElement({ + type: elementType, + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + roundness: + this.state.currentItemArrowType === ARROW_TYPE.round + ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } + : // note, roundness doesn't have any effect for elbow arrows, + // but it's best to set it to null as well + null, + startArrowhead, + endArrowhead, + locked: false, + frameId: topLayerFrame ? topLayerFrame.id : null, + elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, + }) + : newLinearElement({ + type: elementType, + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + roundness: + this.state.currentItemRoundness === "round" + ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } + : null, + locked: false, + frameId: topLayerFrame ? topLayerFrame.id : null, + }); + this.setState((prevState) => { const nextSelectedElementIds = { ...prevState.selectedElementIds, @@ -7015,7 +7124,9 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), + isElbowArrow(element), ); this.scene.insertElement(element); @@ -7352,7 +7463,7 @@ class App extends React.Component { ); }, linearElementEditor, - this.scene.getNonDeletedElementsMap(), + this.scene, ); if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; @@ -7476,18 +7587,24 @@ class App extends React.Component { pointerDownState, selectedElements, dragOffset, - this.state, this.scene, snapOffset, event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, ); - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - ), - }); + if ( + selectedElements.length !== 1 || + !isElbowArrow(selectedElements[0]) + ) { + this.setState({ + suggestedBindings: getSuggestedBindingsForArrows( + selectedElements, + this.scene.getNonDeletedElementsMap(), + ), + }); + } + + //} // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { @@ -7627,6 +7744,17 @@ class App extends React.Component { mutateElement(draggingElement, { points: [...points, [dx, dy]], }); + } else if (points.length > 1 && isElbowArrow(draggingElement)) { + mutateElbowArrow( + draggingElement, + this.scene, + [...points.slice(0, -1), [dx, dy]], + [0, 0], + undefined, + { + isDragging: true, + }, + ); } else if (points.length === 2) { mutateElement(draggingElement, { points: [...points.slice(0, -1), [dx, dy]], @@ -7832,7 +7960,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, - this, + this.scene, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7856,7 +7984,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, - this, + this.scene, ); const { startBindingElement, endBindingElement } = @@ -7868,6 +7996,7 @@ class App extends React.Component { startBindingElement, endBindingElement, elementsMap, + this.scene, ); } @@ -8007,6 +8136,7 @@ class App extends React.Component { this.state, pointerCoords, this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -8568,6 +8698,8 @@ class App extends React.Component { bindOrUnbindLinearElements( linearElements, this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), + this.scene, isBindingEnabled(this.state), this.state.selectedLinearElement?.selectedPointsIndices ?? [], ); @@ -9055,6 +9187,7 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); this.setState({ @@ -9082,7 +9215,9 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), + isArrowElement(linearElement) && isElbowArrow(linearElement), ); if ( hoveredBindableElement != null && @@ -9610,6 +9745,7 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, + this.scene, ) ) { const suggestedBindings = getSuggestedBindingsForArrows( @@ -9926,6 +10062,7 @@ class App extends React.Component { declare global { interface Window { h: { + scene: Scene; elements: readonly ExcalidrawElement[]; state: AppState; setState: React.Component["setState"]; @@ -9952,6 +10089,12 @@ export const createTestHook = () => { ); }, }, + scene: { + configurable: true, + get() { + return this.app?.scene; + }, + }, }); } }; diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 160fcc180d..b52393f6f3 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -30,10 +30,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => { return t("hints.eraserRevert"); } if (activeTool.type === "arrow" || activeTool.type === "line") { - if (!multiMode) { - return t("hints.linearElement"); + if (multiMode) { + return t("hints.linearElementMulti"); } - return t("hints.linearElementMulti"); + if (activeTool.type === "arrow") { + return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") }); + } + return t("hints.linearElement"); } if (activeTool.type === "freedraw") { diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 6727a39562..e83c2f02d5 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -1,6 +1,6 @@ import { mutateElement } from "../../element/mutateElement"; import { getBoundTextElement } from "../../element/textElement"; -import { isArrowElement } from "../../element/typeChecks"; +import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; import type { ExcalidrawElement } from "../../element/types"; import { degreeToRadian, radianToDegree } from "../../math"; import { angleIcon } from "../icons"; @@ -27,8 +27,9 @@ const handleDegreeChange: DragInputCallbackType = ({ scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const origElement = originalElements[0]; - if (origElement) { + if (origElement && !isElbowArrow(origElement)) { const latestElement = elementsMap.get(origElement.id); if (!latestElement) { return; @@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType = ({ mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, elementsMap); + updateBindings(latestElement, elementsMap, elements, scene); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -65,7 +66,7 @@ const handleDegreeChange: DragInputCallbackType = ({ mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, elementsMap); + updateBindings(latestElement, elementsMap, elements, scene); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index 4c3d97bc2e..a68181f918 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -31,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType< scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const origElement = originalElements[0]; if (origElement) { const keepAspectRatio = @@ -61,6 +62,8 @@ const handleDimensionChange: DragInputCallbackType< keepAspectRatio, origElement, elementsMap, + elements, + scene, ); return; @@ -103,6 +106,8 @@ const handleDimensionChange: DragInputCallbackType< keepAspectRatio, origElement, elementsMap, + elements, + scene, ); } }; diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 463aaa2812..97dc57c24d 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -25,9 +25,9 @@ export type DragInputCallbackType< originalElementsMap: ElementsMap; shouldKeepAspectRatio: boolean; shouldChangeByStepSize: boolean; + scene: Scene; nextValue?: number; property: P; - scene: Scene; originalAppState: AppState; }) => void; @@ -122,9 +122,9 @@ const StatsDragInput = < originalElementsMap: app.scene.getNonDeletedElementsMap(), shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldChangeByStepSize: false, + scene, nextValue: rounded, property, - scene, originalAppState: appState, }); app.syncActionResult({ storeAction: StoreAction.CAPTURE }); diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 89b746a5f9..2d7b483086 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -66,8 +66,10 @@ const resizeElementInGroup = ( origElement: ExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, + scene: Scene, ) => { const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); + const { width: oldWidth, height: oldHeight } = latestElement; mutateElement(latestElement, updates, false); const boundTextElement = getBoundTextElement( @@ -76,8 +78,8 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, elementsMap, { - newSize: { width: updates.width, height: updates.height }, + updateBoundElements(latestElement, elementsMap, scene, { + oldSize: { width: oldWidth, height: oldHeight }, }); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { @@ -109,6 +111,7 @@ const resizeGroup = ( originalElements: ExcalidrawElement[], elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, + scene: Scene, ) => { // keep aspect ratio for groups if (property === "width") { @@ -132,6 +135,7 @@ const resizeGroup = ( origElement, elementsMap, originalElementsMap, + scene, ); } }; @@ -149,6 +153,7 @@ const handleDimensionChange: DragInputCallbackType< property, }) => { const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const atomicUnits = getAtomicUnits(originalElements, originalAppState); if (nextValue !== undefined) { for (const atomicUnit of atomicUnits) { @@ -185,6 +190,7 @@ const handleDimensionChange: DragInputCallbackType< originalElements, elementsMap, originalElementsMap, + scene, ); } else { const [el] = elementsInUnit; @@ -227,6 +233,8 @@ const handleDimensionChange: DragInputCallbackType< false, origElement, elementsMap, + elements, + scene, false, ); } @@ -288,6 +296,7 @@ const handleDimensionChange: DragInputCallbackType< originalElements, elementsMap, originalElementsMap, + scene, ); } else { const [el] = elementsInUnit; @@ -320,7 +329,15 @@ const handleDimensionChange: DragInputCallbackType< nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - resizeElement(nextWidth, nextHeight, false, origElement, elementsMap); + resizeElement( + nextWidth, + nextHeight, + false, + origElement, + elementsMap, + elements, + scene, + ); } } } diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index e203908489..652a6b82f5 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -1,6 +1,7 @@ import type { ElementsMap, ExcalidrawElement, + NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "../../element/types"; import { rotate } from "../../math"; @@ -33,6 +34,7 @@ const moveElements = ( originalElements: readonly ExcalidrawElement[], elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, + scene: Scene, ) => { for (let i = 0; i < elements.length; i++) { const origElement = originalElements[i]; @@ -60,6 +62,8 @@ const moveElements = ( newTopLeftY, origElement, elementsMap, + elements, + scene, originalElementsMap, false, ); @@ -71,6 +75,7 @@ const moveGroupTo = ( nextY: number, originalElements: ExcalidrawElement[], elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, ) => { @@ -106,6 +111,8 @@ const moveGroupTo = ( topLeftY + offsetY, origElement, elementsMap, + elements, + scene, originalElementsMap, false, ); @@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType< originalAppState, }) => { const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); if (nextValue !== undefined) { for (const atomicUnit of getAtomicUnits( @@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, elementsInUnit.map((el) => el.original), elementsMap, + elements, originalElementsMap, scene, ); @@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, origElement, elementsMap, + elements, + scene, originalElementsMap, false, ); @@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType< originalElements, elementsMap, originalElementsMap, + scene, ); scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index b3fcc8530c..511aa9c249 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -26,6 +26,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const origElement = originalElements[0]; const [cx, cy] = [ origElement.x + origElement.width / 2, @@ -47,6 +48,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, elementsMap, + elements, + scene, originalElementsMap, ); return; @@ -78,6 +81,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, elementsMap, + elements, + scene, originalElementsMap, ); }; @@ -104,9 +109,9 @@ const Position = ({ label={property === "x" ? "X" : "Y"} elements={[element]} dragInputCallback={handlePositionChange} + scene={scene} value={value} property={property} - scene={scene} appState={appState} /> ); diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index f2d8a53119..5dc1f257e8 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -21,6 +21,7 @@ import type Scene from "../../scene/Scene"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { getAtomicUnits } from "./utils"; import { STATS_PANELS } from "../../constants"; +import { isElbowArrow } from "../../element/typeChecks"; interface StatsProps { scene: Scene; @@ -209,12 +210,14 @@ export const StatsInner = memo( scene={scene} appState={appState} /> - + {!isElbowArrow(singleElement) && ( + + )} { const latestElement = elementsMap.get(origElement.id); @@ -146,6 +149,8 @@ export const resizeElement = ( nextHeight = Math.max(nextHeight, minHeight); } + const { width: oldWidth, height: oldHeight } = latestElement; + mutateElement( latestElement, { @@ -164,7 +169,7 @@ export const resizeElement = ( }, shouldInformMutation, ); - updateBindings(latestElement, elementsMap, { + updateBindings(latestElement, elementsMap, elements, scene, { newSize: { width: nextWidth, height: nextHeight, @@ -193,6 +198,10 @@ export const resizeElement = ( } } + updateBoundElements(latestElement, elementsMap, scene, { + oldSize: { width: oldWidth, height: oldHeight }, + }); + if (boundTextElement && boundTextFont) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, @@ -206,6 +215,8 @@ export const moveElement = ( newTopLeftY: number, originalElement: ExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { @@ -244,7 +255,7 @@ export const moveElement = ( }, shouldInformMutation, ); - updateBindings(latestElement, elementsMap); + updateBindings(latestElement, elementsMap, elements, scene); const boundTextElement = getBoundTextElement( originalElement, @@ -288,14 +299,23 @@ export const getAtomicUnits = ( export const updateBindings = ( latestElement: ExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; }, ) => { if (isLinearElement(latestElement)) { - bindOrUnbindLinearElements([latestElement], elementsMap, true, []); + bindOrUnbindLinearElements( + [latestElement], + elementsMap, + elements, + scene, + true, + [], + ); } else { - updateBoundElements(latestElement, elementsMap, options); + updateBoundElements(latestElement, elementsMap, scene, options); } }; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 0cc0d3d5fb..f4a3a94c21 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2095,6 +2095,35 @@ export const lineEditorIcon = createIcon( tablerIconProps, ); +// arrow-up-right (modified) +export const sharpArrowIcon = createIcon( + + + + + , + tablerIconProps, +); + +// arrow-guide (modified) +export const elbowArrowIcon = createIcon( + + + + + , + tablerIconProps, +); + +// arrow-ramp-right-2 (heavily modified) +export const roundArrowIcon = createIcon( + + + + , + tablerIconProps, +); + export const collapseDownIcon = createIcon( diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index ce754e263a..d73ed0fba2 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -1,5 +1,5 @@ import cssVariables from "./css/variables.module.scss"; -import type { AppProps } from "./types"; +import type { AppProps, AppState } from "./types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); @@ -421,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled"; export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const; export const MIN_WIDTH_OR_HEIGHT = 1; + +export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = { + sharp: "sharp", + round: "round", + elbow: "elbow", +}; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 29cb4c3780..921118eb1f 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -84,9 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", + "fixedPoint": null, "focus": -0.008153707962747813, "gap": 1, }, @@ -117,6 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "id47", + "fixedPoint": null, "focus": -0.08139534883720931, "gap": 1, }, @@ -139,9 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", + "fixedPoint": null, "focus": 0.10666666666666667, "gap": 3.834326468444573, }, @@ -172,6 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "diamond-1", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -328,9 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", + "fixedPoint": null, "focus": 0, "gap": 205, }, @@ -361,6 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startArrowhead": null, "startBinding": { "elementId": "text-1", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -429,9 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id40", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -462,6 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startArrowhead": null, "startBinding": { "elementId": "id39", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -604,9 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id44", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -637,6 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startArrowhead": null, "startBinding": { "elementId": "id43", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -824,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -871,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "triangle", "endBinding": null, "fillStyle": "solid", @@ -1463,9 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "Alice", + "fixedPoint": null, "focus": 0, "gap": 5.299874999999986, }, @@ -1498,6 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1525,9 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "B", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1556,6 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1837,6 +1860,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1889,6 +1913,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1941,6 +1966,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1993,6 +2019,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide }, ], "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index c256e4e02f..0e1e82ccee 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -1,4 +1,5 @@ import type { + ExcalidrawArrowElement, ExcalidrawElement, ExcalidrawElementType, ExcalidrawLinearElement, @@ -24,6 +25,7 @@ import { } from "../element"; import { isArrowElement, + isElbowArrow, isLinearElement, isTextElement, isUsingAdaptiveRadius, @@ -92,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { return DEFAULT_FONT_FAMILY; }; -const repairBinding = (binding: PointBinding | null) => { +const repairBinding = ( + element: ExcalidrawLinearElement, + binding: PointBinding | null, +): PointBinding | null => { if (!binding) { return null; } - return { ...binding, focus: binding.focus || 0 }; + + return { + ...binding, + focus: binding.focus || 0, + fixedPoint: isElbowArrow(element) + ? binding.fixedPoint ?? ([0, 0] as [number, number]) + : null, + }; }; const restoreElementWithProperties = < @@ -242,11 +254,7 @@ const restoreElement = ( // @ts-ignore LEGACY type // eslint-disable-next-line no-fallthrough case "draw": - case "arrow": { - const { - startArrowhead = null, - endArrowhead = element.type === "arrow" ? "arrow" : null, - } = element; + const { startArrowhead = null, endArrowhead = null } = element; let x = element.x; let y = element.y; let points = // migrate old arrow model to new one @@ -266,14 +274,44 @@ const restoreElement = ( (element.type as ExcalidrawElementType | "draw") === "draw" ? "line" : element.type, - startBinding: repairBinding(element.startBinding), - endBinding: repairBinding(element.endBinding), + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), + lastCommittedPoint: null, + startArrowhead, + endArrowhead, + points, + x, + y, + ...getSizeFromPoints(points), + }); + case "arrow": { + const { startArrowhead = null, endArrowhead = "arrow" } = element; + let x = element.x; + let y = element.y; + let points = // migrate old arrow model to new one + !Array.isArray(element.points) || element.points.length < 2 + ? [ + [0, 0], + [element.width, element.height], + ] + : element.points; + + if (points[0][0] !== 0 || points[0][1] !== 0) { + ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); + } + + // TODO: Separate arrow from linear element + return restoreElementWithProperties(element as ExcalidrawArrowElement, { + type: element.type, + startBinding: repairBinding(element, element.startBinding), + endBinding: repairBinding(element, element.endBinding), lastCommittedPoint: null, startArrowhead, endArrowhead, points, x, y, + elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), }); } diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index c7b03ca8aa..bdb37bc967 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -771,6 +771,7 @@ describe("Test Transform", () => { const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", + fixedPoint: null, focus: 0, gap: 205, }); diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 73f00d63a8..cbddafb706 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -13,6 +13,7 @@ import { import { bindLinearElement } from "../element/binding"; import type { ElementConstructorOpts } from "../element/newElement"; import { + newArrowElement, newFrameElement, newImageElement, newMagicFrameElement, @@ -51,6 +52,7 @@ import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; import { syncInvalidIndices } from "../fractionalIndex"; import { getLineHeight } from "../fonts"; +import { isArrowElement } from "../element/typeChecks"; export type ValidLinearElement = { type: "arrow" | "line"; @@ -545,7 +547,7 @@ export const convertToExcalidrawElements = ( case "arrow": { const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width; const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height; - excalidrawElement = newLinearElement({ + excalidrawElement = newArrowElement({ width, height, endArrowhead: "arrow", @@ -554,6 +556,7 @@ export const convertToExcalidrawElements = ( [width, height], ], ...element, + type: "arrow", }); Object.assign( @@ -655,7 +658,7 @@ export const convertToExcalidrawElements = ( elementStore.add(container); elementStore.add(text); - if (container.type === "arrow") { + if (isArrowElement(container)) { const originalStart = element.type === "arrow" ? element?.start : undefined; const originalEnd = @@ -674,7 +677,7 @@ export const convertToExcalidrawElements = ( } const { linearElement, startBoundElement, endBoundElement } = bindLinearElementToElement( - container as ExcalidrawArrowElement, + container, originalStart, originalEnd, elementStore, diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 1bec392390..f3b60c89ca 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -22,8 +22,12 @@ import type { NonDeletedSceneElementsMap, ExcalidrawTextElement, ExcalidrawArrowElement, + OrderedExcalidrawElement, + ExcalidrawElbowArrowElement, + FixedPoint, } from "./types"; +import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import type { AppState, Point } from "../types"; import { isPointOnShape } from "../../utils/collision"; @@ -33,17 +37,38 @@ import { isBindableElement, isBindingElement, isBoundToContainer, + isElbowArrow, isLinearElement, isTextElement, } from "./typeChecks"; import type { ElementUpdate } from "./mutateElement"; import { mutateElement } from "./mutateElement"; -import Scene from "../scene/Scene"; +import type Scene from "../scene/Scene"; import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getElementShape } from "../shapes"; +import { + aabbForElement, + clamp, + distanceSq2d, + getCenterForBounds, + getCenterForElement, + pointInsideBounds, + pointToVector, + rotatePoint, +} from "../math"; +import { + compareHeading, + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, + headingForPointFromElement, + vectorToHeading, + type Heading, +} from "./heading"; export type SuggestedBinding = | NonDeleted @@ -65,6 +90,8 @@ export const isBindingEnabled = (appState: AppState): boolean => { return appState.isBindingEnabled; }; +export const FIXED_BINDING_DISTANCE = 5; + const getNonDeletedElements = ( scene: Scene, ids: readonly ExcalidrawElement["id"][], @@ -84,6 +111,7 @@ export const bindOrUnbindLinearElement = ( startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", elementsMap: NonDeletedSceneElementsMap, + scene: Scene, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -95,6 +123,7 @@ export const bindOrUnbindLinearElement = ( boundToElementIds, unboundFromElementIds, elementsMap, + scene, ); bindOrUnbindLinearElementEdge( linearElement, @@ -104,22 +133,21 @@ export const bindOrUnbindLinearElement = ( boundToElementIds, unboundFromElementIds, elementsMap, + scene, ); const onlyUnbound = Array.from(unboundFromElementIds).filter( (id) => !boundToElementIds.has(id), ); - getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach( - (element) => { - mutateElement(element, { - boundElements: element.boundElements?.filter( - (element) => - element.type !== "arrow" || element.id !== linearElement.id, - ), - }); - }, - ); + getNonDeletedElements(scene, onlyUnbound).forEach((element) => { + mutateElement(element, { + boundElements: element.boundElements?.filter( + (element) => + element.type !== "arrow" || element.id !== linearElement.id, + ), + }); + }); }; const bindOrUnbindLinearElementEdge = ( @@ -132,6 +160,7 @@ const bindOrUnbindLinearElementEdge = ( // Is mutated unboundFromElementIds: Set, elementsMap: NonDeletedSceneElementsMap, + scene: Scene, ): void => { // "keep" is for method chaining convenience, a "no-op", so just bail out if (bindableElement === "keep") { @@ -217,6 +246,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( isBindingEnabled: boolean, draggingPoints: readonly number[], elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], ): (NonDeleted | null | "keep")[] => { const startIdx = 0; const endIdx = selectedElement.points.length - 1; @@ -228,6 +258,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( selectedElement, "start", elementsMap, + elements, ) : null // If binding is disabled and start is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind @@ -235,6 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( selectedElement, "start", elementsMap, + elements, ); const end = endDragged ? isBindingEnabled @@ -242,10 +274,16 @@ const getBindingStrategyForDraggingArrowEndpoints = ( selectedElement, "end", elementsMap, + elements, ) : null // If binding is disabled and end is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind - getElligibleElementForBindingElement(selectedElement, "end", elementsMap); + getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + elements, + ); return [start, end]; }; @@ -253,6 +291,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowOrJoints = ( selectedElement: NonDeleted, elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], isBindingEnabled: boolean, ): (NonDeleted | null | "keep")[] => { const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( @@ -265,6 +304,7 @@ const getBindingStrategyForDraggingArrowOrJoints = ( selectedElement, "start", elementsMap, + elements, ) : null : null; @@ -274,6 +314,7 @@ const getBindingStrategyForDraggingArrowOrJoints = ( selectedElement, "end", elementsMap, + elements, ) : null : null; @@ -284,6 +325,8 @@ const getBindingStrategyForDraggingArrowOrJoints = ( export const bindOrUnbindLinearElements = ( selectedElements: NonDeleted[], elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, isBindingEnabled: boolean, draggingPoints: readonly number[] | null, ): void => { @@ -295,15 +338,17 @@ export const bindOrUnbindLinearElements = ( isBindingEnabled, draggingPoints ?? [], elementsMap, + elements, ) : // The arrow itself (the shaft) or the inner joins are dragged getBindingStrategyForDraggingArrowOrJoints( selectedElement, elementsMap, + elements, isBindingEnabled, ); - bindOrUnbindLinearElement(selectedElement, start, end, elementsMap); + bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); }); }; @@ -343,6 +388,7 @@ export const maybeBindLinearElement = ( appState: AppState, pointerCoords: { x: number; y: number }, elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], ): void => { if (appState.startBoundElement != null) { bindLinearElement( @@ -352,19 +398,24 @@ export const maybeBindLinearElement = ( elementsMap, ); } + const hoveredElement = getHoveredElementForBinding( pointerCoords, + elements, elementsMap, + isElbowArrow(linearElement) && isElbowArrow(linearElement), ); - if ( - hoveredElement != null && - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - hoveredElement, - "end", - ) - ) { - bindLinearElement(linearElement, hoveredElement, "end", elementsMap); + + if (hoveredElement !== null) { + if ( + !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( + linearElement, + hoveredElement, + "end", + ) + ) { + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); + } } }; @@ -377,16 +428,26 @@ export const bindLinearElement = ( if (!isArrowElement(linearElement)) { return; } + const binding: PointBinding = { + elementId: hoveredElement.id, + ...calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + ...(isElbowArrow(linearElement) + ? calculateFixedPointForElbowArrowBinding( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ) + : { fixedPoint: null }), + }; + mutateElement(linearElement, { - [startOrEnd === "start" ? "startBinding" : "endBinding"]: { - elementId: hoveredElement.id, - ...calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - } as PointBinding, + [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, }); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); @@ -448,13 +509,15 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, + elements: readonly NonDeletedExcalidrawElement[], elementsMap: NonDeletedSceneElementsMap, + fullShape?: boolean, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - [...elementsMap].map(([_, value]) => value), + elements, (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords, elementsMap), + bindingBorderTest(element, pointerCoords, elementsMap, fullShape), ); return hoveredElement as NonDeleted | null; }; @@ -501,12 +564,14 @@ const calculateFocusAndGap = ( export const updateBoundElements = ( changedElement: NonDeletedExcalidrawElement, elementsMap: ElementsMap, + scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; + oldSize?: { width: number; height: number }; + changedElements?: Map; }, ) => { - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -524,16 +589,17 @@ export const updateBoundElements = ( if (!doesNeedUpdate(element, changedElement)) { return; } + const bindings = { startBinding: maybeCalculateNewGapWhenScaling( changedElement, element.startBinding, - newSize, + oldSize, ), endBinding: maybeCalculateNewGapWhenScaling( changedElement, element.endBinding, - newSize, + oldSize, ), }; @@ -543,23 +609,58 @@ export const updateBoundElements = ( return; } - bindableElementsVisitor( + const updates = bindableElementsVisitor( elementsMap, element, (bindableElement, bindingProp) => { if ( bindableElement && isBindableElement(bindableElement) && - (bindingProp === "startBinding" || bindingProp === "endBinding") + (bindingProp === "startBinding" || bindingProp === "endBinding") && + changedElement.id === element[bindingProp]?.elementId ) { - updateBoundPoint( + const point = updateBoundPoint( element, bindingProp, bindings[bindingProp], bindableElement, elementsMap, ); + if (point) { + return { + index: + bindingProp === "startBinding" ? 0 : element.points.length - 1, + point, + }; + } } + + return null; + }, + ).filter( + ( + update, + ): update is NonNullable<{ + index: number; + point: Point; + isDragging?: boolean; + }> => update !== null, + ); + + LinearElementEditor.movePoints( + element, + updates, + scene, + { + ...(changedElement.id === element.startBinding?.elementId + ? { startBinding: bindings.startBinding } + : {}), + ...(changedElement.id === element.endBinding?.elementId + ? { endBinding: bindings.endBinding } + : {}), + }, + { + changedElements, }, ); @@ -586,24 +687,342 @@ const getSimultaneouslyUpdatedElementIds = ( return new Set((simultaneouslyUpdated || []).map((element) => element.id)); }; +export const getHeadingForElbowArrowSnap = ( + point: Readonly, + otherPoint: Readonly, + bindableElement: ExcalidrawBindableElement | undefined | null, + aabb: Bounds | undefined | null, + elementsMap: ElementsMap, + origPoint: Point, +): Heading => { + const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point)); + + if (!bindableElement || !aabb) { + return otherPointHeading; + } + + const distance = getDistanceForBinding( + origPoint, + bindableElement, + elementsMap, + ); + + if (!distance) { + return vectorToHeading( + pointToVector(point, getCenterForElement(bindableElement)), + ); + } + + const pointHeading = headingForPointFromElement(bindableElement, aabb, point); + + return pointHeading; +}; + +const getDistanceForBinding = ( + point: Readonly, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => { + const distance = distanceToBindableElement( + bindableElement, + point, + elementsMap, + ); + const bindDistance = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + return distance > bindDistance ? null : distance; +}; + +export const bindPointToSnapToElementOutline = ( + point: Readonly, + otherPoint: Readonly, + bindableElement: ExcalidrawBindableElement | undefined, + elementsMap: ElementsMap, +): Point => { + const aabb = bindableElement && aabbForElement(bindableElement); + + if (bindableElement && aabb) { + // TODO: Dirty hack until tangents are properly calculated + const intersections = [ + ...intersectElementWithLine( + bindableElement, + [point[0], point[1] - 2 * bindableElement.height], + [point[0], point[1] + 2 * bindableElement.height], + FIXED_BINDING_DISTANCE, + elementsMap, + ), + ...intersectElementWithLine( + bindableElement, + [point[0] - 2 * bindableElement.width, point[1]], + [point[0] + 2 * bindableElement.width, point[1]], + FIXED_BINDING_DISTANCE, + elementsMap, + ), + ].map((i) => + distanceToBindableElement(bindableElement, i, elementsMap) > + Math.min(bindableElement.width, bindableElement.height) / 2 + ? ([-1 * i[0], -1 * i[1]] as Point) + : i, + ); + + const heading = headingForPointFromElement(bindableElement, aabb, point); + const isVertical = + compareHeading(heading, HEADING_LEFT) || + compareHeading(heading, HEADING_RIGHT); + const dist = distanceToBindableElement(bindableElement, point, elementsMap); + const isInner = isVertical + ? dist < bindableElement.width * -0.1 + : dist < bindableElement.height * -0.1; + + intersections.sort( + (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point), + ); + + return isInner + ? headingToMidBindPoint(otherPoint, bindableElement, aabb) + : intersections.filter((i) => + isVertical + ? Math.abs(point[1] - i[1]) < 0.1 + : Math.abs(point[0] - i[0]) < 0.1, + )[0] ?? point; + } + + return point; +}; + +const headingToMidBindPoint = ( + point: Point, + bindableElement: ExcalidrawBindableElement, + aabb: Bounds, +): Point => { + const center = getCenterForBounds(aabb); + const heading = vectorToHeading(pointToVector(point, center)); + + switch (true) { + case compareHeading(heading, HEADING_UP): + return rotatePoint( + [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]], + center, + bindableElement.angle, + ); + case compareHeading(heading, HEADING_RIGHT): + return rotatePoint( + [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1], + center, + bindableElement.angle, + ); + case compareHeading(heading, HEADING_DOWN): + return rotatePoint( + [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]], + center, + bindableElement.angle, + ); + default: + return rotatePoint( + [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1], + center, + bindableElement.angle, + ); + } +}; + +export const avoidRectangularCorner = ( + element: ExcalidrawBindableElement, + p: Point, +): Point => { + const center = getCenterForElement(element); + const nonRotatedPoint = rotatePoint(p, center, -element.angle); + + if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { + // Top left + if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { + return rotatePoint( + [element.x - FIXED_BINDING_DISTANCE, element.y], + center, + element.angle, + ); + } + return rotatePoint( + [element.x, element.y - FIXED_BINDING_DISTANCE], + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] < element.x && + nonRotatedPoint[1] > element.y + element.height + ) { + // Bottom left + if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { + return rotatePoint( + [element.x, element.y + element.height + FIXED_BINDING_DISTANCE], + center, + element.angle, + ); + } + return rotatePoint( + [element.x - FIXED_BINDING_DISTANCE, element.y + element.height], + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] > element.x + element.width && + nonRotatedPoint[1] > element.y + element.height + ) { + // Bottom right + if ( + nonRotatedPoint[0] - element.x < + element.width + FIXED_BINDING_DISTANCE + ) { + return rotatePoint( + [ + element.x + element.width, + element.y + element.height + FIXED_BINDING_DISTANCE, + ], + center, + element.angle, + ); + } + return rotatePoint( + [ + element.x + element.width + FIXED_BINDING_DISTANCE, + element.y + element.height, + ], + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] > element.x + element.width && + nonRotatedPoint[1] < element.y + ) { + // Top right + if ( + nonRotatedPoint[0] - element.x < + element.width + FIXED_BINDING_DISTANCE + ) { + return rotatePoint( + [element.x + element.width, element.y - FIXED_BINDING_DISTANCE], + center, + element.angle, + ); + } + return rotatePoint( + [element.x + element.width + FIXED_BINDING_DISTANCE, element.y], + center, + element.angle, + ); + } + + return p; +}; + +export const snapToMid = ( + element: ExcalidrawBindableElement, + p: Point, + tolerance: number = 0.05, +): Point => { + const { x, y, width, height, angle } = element; + const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point; + const nonRotated = rotatePoint(p, center, -angle); + + // snap-to-center point is adaptive to element size, but we don't want to go + // above and below certain px distance + const verticalThrehsold = clamp(tolerance * height, 5, 80); + const horizontalThrehsold = clamp(tolerance * width, 5, 80); + + if ( + nonRotated[0] <= x + width / 2 && + nonRotated[1] > center[1] - verticalThrehsold && + nonRotated[1] < center[1] + verticalThrehsold + ) { + // LEFT + return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle); + } else if ( + nonRotated[1] <= y + height / 2 && + nonRotated[0] > center[0] - horizontalThrehsold && + nonRotated[0] < center[0] + horizontalThrehsold + ) { + // TOP + return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle); + } else if ( + nonRotated[0] >= x + width / 2 && + nonRotated[1] > center[1] - verticalThrehsold && + nonRotated[1] < center[1] + verticalThrehsold + ) { + // RIGHT + return rotatePoint( + [x + width + FIXED_BINDING_DISTANCE, center[1]], + center, + angle, + ); + } else if ( + nonRotated[1] >= y + height / 2 && + nonRotated[0] > center[0] - horizontalThrehsold && + nonRotated[0] < center[0] + horizontalThrehsold + ) { + // DOWN + return rotatePoint( + [center[0], y + height + FIXED_BINDING_DISTANCE], + center, + angle, + ); + } + + return p; +}; + const updateBoundPoint = ( linearElement: NonDeleted, startOrEnd: "startBinding" | "endBinding", binding: PointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, -): void => { +): Point | null => { if ( binding == null || // We only need to update the other end if this is a 2 point line element (binding.elementId !== bindableElement.id && linearElement.points.length > 2) ) { - return; + return null; } const direction = startOrEnd === "startBinding" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; + + if (isElbowArrow(linearElement)) { + const fixedPoint = + binding.fixedPoint ?? + calculateFixedPointForElbowArrowBinding( + linearElement, + bindableElement, + startOrEnd === "startBinding" ? "start" : "end", + elementsMap, + ).fixedPoint; + const globalMidPoint = [ + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ] as Point; + const global = [ + bindableElement.x + fixedPoint[0] * bindableElement.width, + bindableElement.y + fixedPoint[1] * bindableElement.height, + ] as Point; + const rotatedGlobal = rotatePoint( + global, + globalMidPoint, + bindableElement.angle, + ); + + return LinearElementEditor.pointFromAbsoluteCoords( + linearElement, + rotatedGlobal, + elementsMap, + ); + } + const adjacentPointIndex = edgePointIndex - direction; const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, @@ -616,7 +1035,9 @@ const updateBoundPoint = ( adjacentPoint, elementsMap, ); - let newEdgePoint; + + let newEdgePoint: Point; + // The linear element was not originally pointing inside the bound shape, // we can point directly at the focus point if (binding.gap === 0) { @@ -638,20 +1059,62 @@ const updateBoundPoint = ( newEdgePoint = intersections[0]; } } - LinearElementEditor.movePoints( + + return LinearElementEditor.pointFromAbsoluteCoords( linearElement, - [ - { - index: edgePointIndex, - point: LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - newEdgePoint, - elementsMap, - ), - }, - ], - { [startOrEnd]: binding }, + newEdgePoint, + elementsMap, + ); +}; + +export const calculateFixedPointForElbowArrowBinding = ( + linearElement: NonDeleted, + hoveredElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", + elementsMap: ElementsMap, +): { fixedPoint: FixedPoint } => { + const bounds = [ + hoveredElement.x, + hoveredElement.y, + hoveredElement.x + hoveredElement.width, + hoveredElement.y + hoveredElement.height, + ] as Bounds; + const edgePointIndex = + startOrEnd === "start" ? 0 : linearElement.points.length - 1; + const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + const snappedPoint = bindPointToSnapToElementOutline( + globalPoint, + otherGlobalPoint, + hoveredElement, + elementsMap, ); + const globalMidPoint = [ + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, + ] as Point; + const nonRotatedSnappedGlobalPoint = rotatePoint( + snappedPoint, + globalMidPoint, + -hoveredElement.angle, + ) as Point; + + return { + fixedPoint: [ + (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / + hoveredElement.width, + (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / + hoveredElement.height, + ] as [number, number], + }; }; const maybeCalculateNewGapWhenScaling = ( @@ -662,26 +1125,29 @@ const maybeCalculateNewGapWhenScaling = ( 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), + currentBinding.gap * + (newWidth < newHeight ? newWidth / width : newHeight / height), ), ); - return { elementId, gap: newGap, focus }; + + return { ...currentBinding, gap: newGap }; }; const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], ): NonDeleted | null => { return getHoveredElementForBinding( getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elements, elementsMap, ); }; @@ -798,11 +1264,9 @@ const newBindingAfterDuplication = ( if (binding == null) { return null; } - const { elementId, focus, gap } = binding; return { - focus, - gap, - elementId: oldIdToDuplicatedId.get(elementId) ?? elementId, + ...binding, + elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId, }; }; @@ -843,14 +1307,18 @@ const newBoundElements = ( return nextBoundElements; }; -const bindingBorderTest = ( +export const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, + fullShape?: boolean, ): boolean => { const threshold = maxBindingGap(element, element.width, element.height); const shape = getElementShape(element, elementsMap); - return isPointOnShape([x, y], shape, threshold); + return ( + isPointOnShape([x, y], shape, threshold) || + (fullShape === true && pointInsideBounds([x, y], aabbForElement(element))) + ); }; export const maxBindingGap = ( @@ -865,7 +1333,7 @@ export const maxBindingGap = ( return Math.max(16, Math.min(0.25 * smallerDimension, 32)); }; -const distanceToBindableElement = ( +export const distanceToBindableElement = ( element: ExcalidrawBindableElement, point: Point, elementsMap: ElementsMap, @@ -1408,11 +1876,11 @@ type BoundElementsVisitingFunc = ( bindingId: string, ) => void; -type BindableElementVisitingFunc = ( +type BindableElementVisitingFunc = ( bindableElement: ExcalidrawElement | undefined, bindingProp: BindingProp, bindingId: string, -) => void; +) => T; /** * Tries to visit each bound element (does not have to be found). @@ -1436,32 +1904,36 @@ const boundElementsVisitor = ( /** * Tries to visit each bindable element (does not have to be found). */ -const bindableElementsVisitor = ( +const bindableElementsVisitor = ( elements: ElementsMap, element: ExcalidrawElement, - visit: BindableElementVisitingFunc, -) => { + visit: BindableElementVisitingFunc, +): T[] => { + const result: T[] = []; + if (element.frameId) { const id = element.frameId; - visit(elements.get(id), "frameId", id); + result.push(visit(elements.get(id), "frameId", id)); } if (isBoundToContainer(element)) { const id = element.containerId; - visit(elements.get(id), "containerId", id); + result.push(visit(elements.get(id), "containerId", id)); } if (isArrowElement(element)) { if (element.startBinding) { const id = element.startBinding.elementId; - visit(elements.get(id), "startBinding", id); + result.push(visit(elements.get(id), "startBinding", id)); } if (element.endBinding) { const id = element.endBinding.elementId; - visit(elements.get(id), "endBinding", id); + result.push(visit(elements.get(id), "endBinding", id)); } } + + return result; }; /** @@ -1689,3 +2161,62 @@ export class BindableElement { ); }; } + +export const getGlobalFixedPointForBindableElement = ( + fixedPointRatio: [number, number], + element: ExcalidrawBindableElement, +) => { + const [fixedX, fixedY] = fixedPointRatio; + return rotatePoint( + [element.x + element.width * fixedX, element.y + element.height * fixedY], + getCenterForElement(element), + element.angle, + ); +}; + +const getGlobalFixedPoints = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: ElementsMap, +) => { + const startElement = + arrow.startBinding && + (elementsMap.get(arrow.startBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const endElement = + arrow.endBinding && + (elementsMap.get(arrow.endBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const startPoint: Point = + startElement && arrow.startBinding + ? getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + startElement as ExcalidrawBindableElement, + ) + : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]]; + const endPoint: Point = + endElement && arrow.endBinding + ? getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + endElement as ExcalidrawBindableElement, + ) + : [ + arrow.x + arrow.points[arrow.points.length - 1][0], + arrow.y + arrow.points[arrow.points.length - 1][1], + ]; + + return [startPoint, endPoint]; +}; + +export const getArrowLocalFixedPoints = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: ElementsMap, +) => { + const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap); + + return [ + LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap), + LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap), + ]; +}; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 2c951148e4..f031eb4123 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -10,6 +10,7 @@ import { getGridPoint } from "../math"; import type Scene from "../scene/Scene"; import { isArrowElement, + isElbowArrow, isFrameLikeElement, isTextElement, } from "./typeChecks"; @@ -18,9 +19,8 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; export const dragSelectedElements = ( pointerDownState: PointerDownState, - selectedElements: NonDeletedExcalidrawElement[], + _selectedElements: NonDeletedExcalidrawElement[], offset: { x: number; y: number }, - appState: AppState, scene: Scene, snapOffset: { x: number; @@ -28,6 +28,25 @@ export const dragSelectedElements = ( }, gridSize: AppState["gridSize"], ) => { + if ( + _selectedElements.length === 1 && + isArrowElement(_selectedElements[0]) && + isElbowArrow(_selectedElements[0]) && + (_selectedElements[0].startBinding || _selectedElements[0].endBinding) + ) { + return; + } + + const selectedElements = _selectedElements.filter( + (el) => + !( + isArrowElement(el) && + isElbowArrow(el) && + el.startBinding && + el.endBinding + ), + ); + // we do not want a frame and its elements to be selected at the same time // but when it happens (due to some bug), we want to avoid updating element // in the frame twice, hence the use of set @@ -72,9 +91,14 @@ export const dragSelectedElements = ( updateElementCoords(pointerDownState, textElement, adjustedOffset); } } - updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { - simultaneouslyUpdated: Array.from(elementsToUpdate), - }); + updateBoundElements( + element, + scene.getElementsMapIncludingDeleted(), + scene, + { + simultaneouslyUpdated: Array.from(elementsToUpdate), + }, + ); }); }; diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts new file mode 100644 index 0000000000..a8b3a3fa01 --- /dev/null +++ b/packages/excalidraw/element/heading.ts @@ -0,0 +1,146 @@ +import { lineAngle } from "../../utils/geometry/geometry"; +import type { Point, Vector } from "../../utils/geometry/shape"; +import { + getCenterForBounds, + PointInTriangle, + rotatePoint, + scalePointFromOrigin, +} from "../math"; +import type { Bounds } from "./bounds"; +import type { ExcalidrawBindableElement } from "./types"; + +export const HEADING_RIGHT = [1, 0] as Heading; +export const HEADING_DOWN = [0, 1] as Heading; +export const HEADING_LEFT = [-1, 0] as Heading; +export const HEADING_UP = [0, -1] as Heading; +export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; + +export const headingForDiamond = (a: Point, b: Point) => { + const angle = lineAngle([a, b]); + if (angle >= 315 || angle < 45) { + return HEADING_UP; + } else if (angle >= 45 && angle < 135) { + return HEADING_RIGHT; + } else if (angle >= 135 && angle < 225) { + return HEADING_DOWN; + } + return HEADING_LEFT; +}; + +export const vectorToHeading = (vec: Vector): Heading => { + const [x, y] = vec; + const absX = Math.abs(x); + const absY = Math.abs(y); + if (x > absY) { + return HEADING_RIGHT; + } else if (x <= -absY) { + return HEADING_LEFT; + } else if (y > absX) { + return HEADING_DOWN; + } + return HEADING_UP; +}; + +export const compareHeading = (a: Heading, b: Heading) => + a[0] === b[0] && a[1] === b[1]; + +// Gets the heading for the point by creating a bounding box around the rotated +// close fitting bounding box, then creating 4 search cones around the center of +// the external bbox. +export const headingForPointFromElement = ( + element: Readonly, + aabb: Readonly, + point: Readonly, +): Heading => { + const SEARCH_CONE_MULTIPLIER = 2; + + const midPoint = getCenterForBounds(aabb); + + if (element.type === "diamond") { + if (point[0] < element.x) { + return HEADING_LEFT; + } else if (point[1] < element.y) { + return HEADING_UP; + } else if (point[0] > element.x + element.width) { + return HEADING_RIGHT; + } else if (point[1] > element.y + element.height) { + return HEADING_DOWN; + } + + const top = rotatePoint( + scalePointFromOrigin( + [element.x + element.width / 2, element.y], + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const right = rotatePoint( + scalePointFromOrigin( + [element.x + element.width, element.y + element.height / 2], + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const bottom = rotatePoint( + scalePointFromOrigin( + [element.x + element.width / 2, element.y + element.height], + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const left = rotatePoint( + scalePointFromOrigin( + [element.x, element.y + element.height / 2], + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + + if (PointInTriangle(point, top, right, midPoint)) { + return headingForDiamond(top, right); + } else if (PointInTriangle(point, right, bottom, midPoint)) { + return headingForDiamond(right, bottom); + } else if (PointInTriangle(point, bottom, left, midPoint)) { + return headingForDiamond(bottom, left); + } + + return headingForDiamond(left, top); + } + + const topLeft = scalePointFromOrigin( + [aabb[0], aabb[1]], + midPoint, + SEARCH_CONE_MULTIPLIER, + ); + const topRight = scalePointFromOrigin( + [aabb[2], aabb[1]], + midPoint, + SEARCH_CONE_MULTIPLIER, + ); + const bottomLeft = scalePointFromOrigin( + [aabb[0], aabb[3]], + midPoint, + SEARCH_CONE_MULTIPLIER, + ); + const bottomRight = scalePointFromOrigin( + [aabb[2], aabb[3]], + midPoint, + SEARCH_CONE_MULTIPLIER, + ); + + return PointInTriangle(point, topLeft, topRight, midPoint) + ? HEADING_UP + : PointInTriangle(point, topRight, bottomRight, midPoint) + ? HEADING_RIGHT + : PointInTriangle(point, bottomRight, bottomLeft, midPoint) + ? HEADING_DOWN + : HEADING_LEFT; +}; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 35661608e4..d6bc8f6156 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -11,6 +11,7 @@ export { newTextElement, refreshTextDimensions, newLinearElement, + newArrowElement, newImageElement, duplicateElement, } from "./newElement"; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 9719227628..68e400649b 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -7,6 +7,8 @@ import type { ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, + OrderedExcalidrawElement, + FixedPointBinding, } from "./types"; import { distance2d, @@ -33,7 +35,6 @@ import type { AppState, PointerCoords, InteractiveCanvasAppState, - AppClassProperties, } from "../types"; import { mutateElement } from "./mutateElement"; @@ -43,13 +44,19 @@ import { isBindingEnabled, } from "./binding"; import { tupleToCoors } from "../utils"; -import { isBindingElement } from "./typeChecks"; +import { + isBindingElement, + isElbowArrow, + isFixedPointBinding, +} from "./typeChecks"; import { KEYS, shouldRotateWithDiscreteAngle } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { DRAGGING_THRESHOLD } from "../constants"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; import type { Store } from "../store"; +import { mutateElbowArrow } from "./routing"; +import type Scene from "../scene/Scene"; const editorMidPointsCache: { version: number | null; @@ -67,6 +74,7 @@ export class LinearElementEditor { prevSelectedPointsIndices: readonly number[] | null; /** index */ lastClickedPoint: number; + lastClickedIsEndPoint: boolean; origin: Readonly<{ x: number; y: number }> | null; segmentMidpoint: { value: Point | null; @@ -91,7 +99,9 @@ export class LinearElementEditor { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - LinearElementEditor.normalizePoints(element); + if (!arePointsEqual(element.points[0], [0, 0])) { + console.error("Linear element is not normalized", Error().stack); + } this.selectedPointsIndices = null; this.lastUncommittedPoint = null; @@ -102,6 +112,7 @@ export class LinearElementEditor { this.pointerDownState = { prevSelectedPointsIndices: null, lastClickedPoint: -1, + lastClickedIsEndPoint: false, origin: null, segmentMidpoint: { @@ -162,8 +173,8 @@ export class LinearElementEditor { elementsMap, ); - const nextSelectedPoints = pointsSceneCoords.reduce( - (acc: number[], point, index) => { + const nextSelectedPoints = pointsSceneCoords + .reduce((acc: number[], point, index) => { if ( (point[0] >= selectionX1 && point[0] <= selectionX2 && @@ -175,9 +186,17 @@ export class LinearElementEditor { } return acc; - }, - [], - ); + }, []) + .filter((index) => { + if ( + isElbowArrow(element) && + index !== 0 && + index !== element.points.length - 1 + ) { + return false; + } + return true; + }); setState({ editingLinearElement: { @@ -200,21 +219,52 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, - elementsMap: NonDeletedSceneElementsMap, + scene: Scene, ): boolean { if (!linearElementEditor) { return false; } - const { selectedPointsIndices, elementId } = linearElementEditor; + const { elementId } = linearElementEditor; + const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } + if ( + isElbowArrow(element) && + !linearElementEditor.pointerDownState.lastClickedIsEndPoint && + linearElementEditor.pointerDownState.lastClickedPoint !== 0 + ) { + return false; + } + + const selectedPointsIndices = isElbowArrow(element) + ? linearElementEditor.selectedPointsIndices + ?.reduce( + (startEnd, index) => + (index === 0 + ? [0, startEnd[1]] + : [startEnd[0], element.points.length - 1]) as [ + boolean | number, + boolean | number, + ], + [false, false] as [number | boolean, number | boolean], + ) + .filter( + (idx: number | boolean): idx is number => typeof idx === "number", + ) + : linearElementEditor.selectedPointsIndices; + const lastClickedPoint = isElbowArrow(element) + ? linearElementEditor.pointerDownState.lastClickedPoint > 0 + ? element.points.length - 1 + : 0 + : linearElementEditor.pointerDownState.lastClickedPoint; + // point that's being dragged (out of all selected points) - const draggingPoint = element.points[ - linearElementEditor.pointerDownState.lastClickedPoint - ] as [number, number] | undefined; + const draggingPoint = element.points[lastClickedPoint] as + | [number, number] + | undefined; if (selectedPointsIndices && draggingPoint) { if ( @@ -234,15 +284,17 @@ export class LinearElementEditor { event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, ); - LinearElementEditor.movePoints(element, [ - { - index: selectedIndex, - point: [width + referencePoint[0], height + referencePoint[1]], - isDragging: - selectedIndex === - linearElementEditor.pointerDownState.lastClickedPoint, - }, - ]); + LinearElementEditor.movePoints( + element, + [ + { + index: selectedIndex, + point: [width + referencePoint[0], height + referencePoint[1]], + isDragging: selectedIndex === lastClickedPoint, + }, + ], + scene, + ); } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, @@ -259,8 +311,7 @@ export class LinearElementEditor { element, selectedPointsIndices.map((pointIndex) => { const newPointPosition = - pointIndex === - linearElementEditor.pointerDownState.lastClickedPoint + pointIndex === lastClickedPoint ? LinearElementEditor.createPointAt( element, elementsMap, @@ -275,11 +326,10 @@ export class LinearElementEditor { return { index: pointIndex, point: newPointPosition, - isDragging: - pointIndex === - linearElementEditor.pointerDownState.lastClickedPoint, + isDragging: pointIndex === lastClickedPoint, }; }), + scene, ); } @@ -334,9 +384,10 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, - app: AppClassProperties, + scene: Scene, ): LinearElementEditor { - const elementsMap = app.scene.getNonDeletedElementsMap(); + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; @@ -361,15 +412,19 @@ export class LinearElementEditor { selectedPoint === element.points.length - 1 ) { if (isPathALoop(element.points, appState.zoom.value)) { - LinearElementEditor.movePoints(element, [ - { - index: selectedPoint, - point: - selectedPoint === 0 - ? element.points[element.points.length - 1] - : element.points[0], - }, - ]); + LinearElementEditor.movePoints( + element, + [ + { + index: selectedPoint, + point: + selectedPoint === 0 + ? element.points[element.points.length - 1] + : element.points[0], + }, + ], + scene, + ); } const bindingElement = isBindingEnabled(appState) @@ -381,6 +436,7 @@ export class LinearElementEditor { elementsMap, ), ), + elements, elementsMap, ) : null; @@ -645,13 +701,14 @@ export class LinearElementEditor { store: Store, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, - app: AppClassProperties, + scene: Scene, ): { didAddPoint: boolean; hitElement: NonDeleted | null; linearElementEditor: LinearElementEditor | null; } { - const elementsMap = app.scene.getNonDeletedElementsMap(); + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -685,7 +742,10 @@ export class LinearElementEditor { ); } if (event.altKey && appState.editingLinearElement) { - if (linearElementEditor.lastUncommittedPoint == null) { + if ( + linearElementEditor.lastUncommittedPoint == null || + !isElbowArrow(element) + ) { mutateElement(element, { points: [ ...element.points, @@ -706,6 +766,7 @@ export class LinearElementEditor { pointerDownState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: -1, + lastClickedIsEndPoint: false, origin: { x: scenePointer.x, y: scenePointer.y }, segmentMidpoint: { value: segmentMidpoint, @@ -717,6 +778,7 @@ export class LinearElementEditor { lastUncommittedPoint: null, endBindingElement: getHoveredElementForBinding( scenePointer, + elements, elementsMap, ), }; @@ -749,6 +811,7 @@ export class LinearElementEditor { startBindingElement, endBindingElement, elementsMap, + scene, ); } } @@ -781,6 +844,7 @@ export class LinearElementEditor { pointerDownState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: clickedPointIndex, + lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, origin: { x: scenePointer.x, y: scenePointer.y }, segmentMidpoint: { value: segmentMidpoint, @@ -815,12 +879,13 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, appState: AppState, - elementsMap: ElementsMap, + scene: Scene, ): LinearElementEditor | null { if (!appState.editingLinearElement) { return null; } const { elementId, lastUncommittedPoint } = appState.editingLinearElement; + const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return appState.editingLinearElement; @@ -831,7 +896,7 @@ export class LinearElementEditor { if (!event.altKey) { if (lastPoint === lastUncommittedPoint) { - LinearElementEditor.deletePoints(element, [points.length - 1]); + LinearElementEditor.deletePoints(element, [points.length - 1], scene); } return { ...appState.editingLinearElement, @@ -862,19 +927,30 @@ export class LinearElementEditor { elementsMap, scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerY - appState.editingLinearElement.pointerOffset.y, - event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) + ? null + : appState.gridSize, ); } if (lastPoint === lastUncommittedPoint) { - LinearElementEditor.movePoints(element, [ - { - index: element.points.length - 1, - point: newPoint, - }, - ]); + LinearElementEditor.movePoints( + element, + [ + { + index: element.points.length - 1, + point: newPoint, + }, + ], + scene, + ); } else { - LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]); + LinearElementEditor.addPoints( + element, + appState, + [{ point: newPoint }], + scene, + ); } return { ...appState.editingLinearElement, @@ -938,6 +1014,11 @@ export class LinearElementEditor { absoluteCoords: Point, elementsMap: ElementsMap, ): Point { + if (isElbowArrow(element)) { + // No rotation for elbow arrows + return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; + } + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -1028,13 +1109,13 @@ export class LinearElementEditor { mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); } - static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) { + static duplicateSelectedPoints(appState: AppState, scene: Scene) { if (!appState.editingLinearElement) { return false; } const { selectedPointsIndices, elementId } = appState.editingLinearElement; - + const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element || selectedPointsIndices === null) { @@ -1077,12 +1158,16 @@ export class LinearElementEditor { // potentially expanding the bounding box if (pointAddedToEnd) { const lastPoint = element.points[element.points.length - 1]; - LinearElementEditor.movePoints(element, [ - { - index: element.points.length - 1, - point: [lastPoint[0] + 30, lastPoint[1] + 30], - }, - ]); + LinearElementEditor.movePoints( + element, + [ + { + index: element.points.length - 1, + point: [lastPoint[0] + 30, lastPoint[1] + 30], + }, + ], + scene, + ); } return { @@ -1099,6 +1184,7 @@ export class LinearElementEditor { static deletePoints( element: NonDeleted, pointIndices: readonly number[], + scene: Scene, ) { let offsetX = 0; let offsetY = 0; @@ -1126,25 +1212,46 @@ export class LinearElementEditor { return acc; }, []); - LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + LinearElementEditor._updatePoints( + element, + nextPoints, + offsetX, + offsetY, + scene, + ); } static addPoints( element: NonDeleted, appState: AppState, targetPoints: { point: Point }[], + scene: Scene, ) { const offsetX = 0; const offsetY = 0; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; - LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + LinearElementEditor._updatePoints( + element, + nextPoints, + offsetX, + offsetY, + scene, + ); } static movePoints( element: NonDeleted, targetPoints: { index: number; point: Point; isDragging?: boolean }[], - otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, + scene: Scene, + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + options?: { + changedElements?: Map; + isDragging?: boolean; + }, ) { const { points } = element; @@ -1192,7 +1299,16 @@ export class LinearElementEditor { nextPoints, offsetX, offsetY, + scene, otherUpdates, + { + isDragging: targetPoints.reduce( + (dragging, targetPoint): boolean => + dragging || targetPoint.isDragging === true, + false, + ), + changedElements: options?.changedElements, + }, ); } @@ -1207,6 +1323,11 @@ export class LinearElementEditor { elementsMap, ); + // Elbow arrows don't allow midpoints + if (element && isElbowArrow(element)) { + return false; + } + if (!element) { return false; } @@ -1266,7 +1387,7 @@ export class LinearElementEditor { elementsMap, pointerCoords.x, pointerCoords.y, - snapToGrid ? appState.gridSize : null, + snapToGrid && !isElbowArrow(element) ? appState.gridSize : null, ); const points = [ ...element.points.slice(0, segmentMidpoint.index!), @@ -1295,23 +1416,61 @@ export class LinearElementEditor { nextPoints: readonly Point[], offsetX: number, offsetY: number, - otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, + scene: Scene, + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + options?: { + changedElements?: Map; + isDragging?: boolean; + }, ) { - const nextCoords = getElementPointsCoords(element, nextPoints); - const prevCoords = getElementPointsCoords(element, element.points); - const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; - const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; - const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; - const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; - const dX = prevCenterX - nextCenterX; - const dY = prevCenterY - nextCenterY; - const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); - mutateElement(element, { - ...otherUpdates, - points: nextPoints, - x: element.x + rotated[0], - y: element.y + rotated[1], - }); + if (isElbowArrow(element)) { + const bindings: { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + } = {}; + if (otherUpdates?.startBinding !== undefined) { + bindings.startBinding = + otherUpdates.startBinding !== null && + isFixedPointBinding(otherUpdates.startBinding) + ? otherUpdates.startBinding + : null; + } + if (otherUpdates?.endBinding !== undefined) { + bindings.endBinding = + otherUpdates.endBinding !== null && + isFixedPointBinding(otherUpdates.endBinding) + ? otherUpdates.endBinding + : null; + } + + mutateElbowArrow( + element, + scene, + nextPoints, + [offsetX, offsetY], + bindings, + options, + ); + } else { + const nextCoords = getElementPointsCoords(element, nextPoints); + const prevCoords = getElementPointsCoords(element, element.points); + const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; + const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; + const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; + const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; + const dX = prevCenterX - nextCenterX; + const dY = prevCenterY - nextCenterY; + const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); + mutateElement(element, { + ...otherUpdates, + points: nextPoints, + x: element.x + rotated[0], + y: element.y + rotated[1], + }); + } } private static _getShiftLockedDelta( @@ -1327,6 +1486,13 @@ export class LinearElementEditor { elementsMap, ); + if (isElbowArrow(element)) { + return [ + scenePointer[0] - referencePointCoords[0], + scenePointer[1] - referencePointCoords[1], + ]; + } + const [gridX, gridY] = getGridPoint( scenePointer[0], scenePointer[1], diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts index 3aa5a6ba31..3346218b1b 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/packages/excalidraw/element/newElement.test.ts @@ -121,6 +121,7 @@ describe("duplicating multiple elements", () => { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, }); @@ -131,6 +132,7 @@ describe("duplicating multiple elements", () => { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, boundElements: [{ id: "text2", type: "text" }], }); @@ -247,6 +249,7 @@ describe("duplicating multiple elements", () => { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, }); @@ -263,11 +266,13 @@ describe("duplicating multiple elements", () => { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, endBinding: { elementId: "rectangle-not-exists", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, }); @@ -278,11 +283,13 @@ describe("duplicating multiple elements", () => { elementId: "rectangle-not-exists", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, endBinding: { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, }); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 048fd3281a..1f9bd68058 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -17,6 +17,7 @@ import type { ExcalidrawMagicFrameElement, ExcalidrawIframeElement, ElementsMap, + ExcalidrawArrowElement, } from "./types"; import { arrayToMap, @@ -388,8 +389,6 @@ export const newFreeDrawElement = ( export const newLinearElement = ( opts: { type: ExcalidrawLinearElement["type"]; - startArrowhead?: Arrowhead | null; - endArrowhead?: Arrowhead | null; points?: ExcalidrawLinearElement["points"]; } & ElementConstructorOpts, ): NonDeleted => { @@ -399,8 +398,29 @@ export const newLinearElement = ( lastCommittedPoint: null, startBinding: null, endBinding: null, + startArrowhead: null, + endArrowhead: null, + }; +}; + +export const newArrowElement = ( + opts: { + type: ExcalidrawArrowElement["type"]; + startArrowhead?: Arrowhead | null; + endArrowhead?: Arrowhead | null; + points?: ExcalidrawArrowElement["points"]; + elbowed?: boolean; + } & ElementConstructorOpts, +): NonDeleted => { + return { + ..._newElementBase(opts.type, opts), + points: opts.points || [], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, startArrowhead: opts.startArrowhead || null, endArrowhead: opts.endArrowhead || null, + elbowed: opts.elbowed || false, }; }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index c069c2e34f..ddf9fb1da8 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -22,6 +22,7 @@ import { import { isArrowElement, isBoundToContainer, + isElbowArrow, isFrameLikeElement, isFreeDrawElement, isImageElement, @@ -30,7 +31,7 @@ import { } from "./typeChecks"; import { mutateElement } from "./mutateElement"; import { getFontString } from "../utils"; -import { updateBoundElements } from "./binding"; +import { getArrowLocalFixedPoints, updateBoundElements } from "./binding"; import type { MaybeTransformHandleType, TransformHandleDirection, @@ -51,6 +52,7 @@ import { } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; +import { mutateElbowArrow } from "./routing"; export const normalizeAngle = (angle: number): number => { if (angle < 0) { @@ -75,18 +77,21 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, + scene: Scene, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; if (transformHandleType === "rotation") { - rotateSingleElement( - element, - elementsMap, - pointerX, - pointerY, - shouldRotateWithDiscreteAngle, - ); - updateBoundElements(element, elementsMap); + if (!isElbowArrow(element)) { + rotateSingleElement( + element, + elementsMap, + pointerX, + pointerY, + shouldRotateWithDiscreteAngle, + ); + updateBoundElements(element, elementsMap, scene); + } } else if (isTextElement(element) && transformHandleType) { resizeSingleTextElement( originalElements, @@ -97,7 +102,7 @@ export const transformElements = ( pointerX, pointerY, ); - updateBoundElements(element, elementsMap); + updateBoundElements(element, elementsMap, scene); } else if (transformHandleType) { resizeSingleElement( originalElements, @@ -108,6 +113,7 @@ export const transformElements = ( shouldResizeFromCenter, pointerX, pointerY, + scene, ); } @@ -123,6 +129,7 @@ export const transformElements = ( shouldRotateWithDiscreteAngle, centerX, centerY, + scene, ); return true; } else if (transformHandleType) { @@ -135,6 +142,7 @@ export const transformElements = ( shouldMaintainAspectRatio, pointerX, pointerY, + scene, ); return true; } @@ -431,7 +439,17 @@ export const resizeSingleElement = ( shouldResizeFromCenter: boolean, pointerX: number, pointerY: number, + scene: Scene, ) => { + // Elbow arrows cannot be resized when bound on either end + if ( + isArrowElement(element) && + isElbowArrow(element) && + (element.startBinding || element.endBinding) + ) { + return; + } + const stateAtResizeStart = originalElements.get(element.id)!; // Gets bounds corners const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( @@ -701,8 +719,11 @@ export const resizeSingleElement = ( ) { mutateElement(element, resizedElement); - updateBoundElements(element, elementsMap, { - newSize: { width: resizedElement.width, height: resizedElement.height }, + updateBoundElements(element, elementsMap, scene, { + oldSize: { + width: stateAtResizeStart.width, + height: stateAtResizeStart.height, + }, }); if (boundTextElement && boundTextFont != null) { @@ -728,6 +749,7 @@ export const resizeMultipleElements = ( shouldMaintainAspectRatio: boolean, pointerX: number, pointerY: number, + scene: Scene, ) => { // map selected elements to the original elements. While it never should // happen that pointerDownState.originalElements won't contain the selected @@ -955,13 +977,20 @@ export const resizeMultipleElements = ( element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { - const { width, height, angle } = update; + const { angle } = update; + const { width: oldWidth, height: oldHeight } = element; mutateElement(element, update, false); - updateBoundElements(element, elementsMap, { + if (isArrowElement(element) && isElbowArrow(element)) { + mutateElbowArrow(element, scene, element.points, undefined, undefined, { + informMutation: false, + }); + } + + updateBoundElements(element, elementsMap, scene, { simultaneouslyUpdated: elementsToUpdate, - newSize: { width, height }, + oldSize: { width: oldWidth, height: oldHeight }, }); const boundTextElement = getBoundTextElement(element, elementsMap); @@ -990,6 +1019,7 @@ const rotateMultipleElements = ( shouldRotateWithDiscreteAngle: boolean, centerX: number, centerY: number, + scene: Scene, ) => { let centerAngle = (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); @@ -1013,16 +1043,23 @@ const rotateMultipleElements = ( centerY, centerAngle + origAngle - element.angle, ); - mutateElement( - element, - { - x: element.x + (rotatedCX - cx), - y: element.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), - }, - false, - ); - updateBoundElements(element, elementsMap, { + + if (isArrowElement(element) && isElbowArrow(element)) { + const points = getArrowLocalFixedPoints(element, elementsMap); + mutateElbowArrow(element, scene, points); + } else { + mutateElement( + element, + { + x: element.x + (rotatedCX - cx), + y: element.y + (rotatedCY - cy), + angle: normalizeAngle(centerAngle + origAngle), + }, + false, + ); + } + + updateBoundElements(element, elementsMap, scene, { simultaneouslyUpdated: elements, }); diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx new file mode 100644 index 0000000000..d159deeaa3 --- /dev/null +++ b/packages/excalidraw/element/routing.test.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import Scene from "../scene/Scene"; +import { API } from "../tests/helpers/api"; +import { Pointer, UI } from "../tests/helpers/ui"; +import { + fireEvent, + GlobalTestState, + queryByTestId, + render, +} from "../tests/test-utils"; +import { bindLinearElement } from "./binding"; +import { Excalidraw } from "../index"; +import { mutateElbowArrow } from "./routing"; +import type { + ExcalidrawArrowElement, + ExcalidrawBindableElement, + ExcalidrawElbowArrowElement, +} from "./types"; +import { ARROW_TYPE } from "../constants"; + +const { h } = window; + +const mouse = new Pointer("mouse"); + +const editInput = (input: HTMLInputElement, value: string) => { + input.focus(); + fireEvent.change(input, { target: { value } }); + input.blur(); +}; + +const getStatsProperty = (label: string) => { + const elementStats = UI.queryStats()?.querySelector("#elementStats"); + + if (elementStats) { + const properties = elementStats?.querySelector(".statsItem"); + return ( + properties?.querySelector?.( + `.drag-input-container[data-testid="${label}"]`, + ) || null + ); + } + + return null; +}; + +describe("elbow arrow routing", () => { + it("can properly generate orthogonal arrow points", () => { + const scene = new Scene(); + const arrow = API.createElement({ + type: "arrow", + elbowed: true, + }) as ExcalidrawElbowArrowElement; + scene.insertElement(arrow); + mutateElbowArrow(arrow, scene, [ + [-45 - arrow.x, -100.1 - arrow.y], + [45 - arrow.x, 99.9 - arrow.y], + ]); + expect(arrow.points).toEqual([ + [0, 0], + [0, 100], + [90, 100], + [90, 200], + ]); + expect(arrow.x).toEqual(-45); + expect(arrow.y).toEqual(-100.1); + expect(arrow.width).toEqual(90); + expect(arrow.height).toEqual(200); + }); + it("can generate proper points for bound elbow arrow", () => { + const scene = new Scene(); + const rectangle1 = API.createElement({ + type: "rectangle", + x: -150, + y: -150, + width: 100, + height: 100, + }) as ExcalidrawBindableElement; + const rectangle2 = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 100, + height: 100, + }) as ExcalidrawBindableElement; + const arrow = API.createElement({ + type: "arrow", + elbowed: true, + x: -45, + y: -100.1, + width: 90, + height: 200, + points: [ + [0, 0], + [90, 200], + ], + }) as ExcalidrawElbowArrowElement; + scene.insertElement(rectangle1); + scene.insertElement(rectangle2); + scene.insertElement(arrow); + const elementsMap = scene.getNonDeletedElementsMap(); + bindLinearElement(arrow, rectangle1, "start", elementsMap); + bindLinearElement(arrow, rectangle2, "end", elementsMap); + + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).not.toBe(null); + + mutateElbowArrow(arrow, scene, [ + [0, 0], + [90, 200], + ]); + + expect(arrow.points).toEqual([ + [0, 0], + [45, 0], + [45, 200], + [90, 200], + ]); + }); +}); + +describe("elbow arrow ui", () => { + beforeEach(async () => { + await render(); + }); + + it("can follow bound shapes", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.type).toBe("arrow"); + expect(arrow.elbowed).toBe(true); + expect(arrow.points).toEqual([ + [0, 0], + [35, 0], + [35, 200], + [90, 200], + ]); + }); + + it("can follow bound rotated shapes", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + + mouse.click(51, 51); + + const inputAngle = getStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + editInput(inputAngle, String("40")); + + expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ + [0, 0], + [35, 0], + [35, 90], + [25, 90], + [25, 165], + [103, 165], + ]); + }); +}); diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts new file mode 100644 index 0000000000..d4745a691c --- /dev/null +++ b/packages/excalidraw/element/routing.ts @@ -0,0 +1,1036 @@ +import { cross } from "../../utils/geometry/geometry"; +import BinaryHeap from "../binaryheap"; +import { + aabbForElement, + arePointsEqual, + pointInsideBounds, + pointToVector, + scalePointFromOrigin, + scaleVector, + translatePoint, +} from "../math"; +import { getSizeFromPoints } from "../points"; +import type Scene from "../scene/Scene"; +import type { Point } from "../types"; +import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { + bindPointToSnapToElementOutline, + distanceToBindableElement, + avoidRectangularCorner, + getHoveredElementForBinding, + FIXED_BINDING_DISTANCE, + getHeadingForElbowArrowSnap, + getGlobalFixedPointForBindableElement, + snapToMid, +} from "./binding"; +import type { Bounds } from "./bounds"; +import type { Heading } from "./heading"; +import { + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, + vectorToHeading, +} from "./heading"; +import { mutateElement } from "./mutateElement"; +import { isBindableElement, isRectanguloidElement } from "./typeChecks"; +import type { + ExcalidrawElbowArrowElement, + FixedPointBinding, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "./types"; +import type { + ElementsMap, + ExcalidrawBindableElement, + OrderedExcalidrawElement, +} from "./types"; + +type Node = { + f: number; + g: number; + h: number; + closed: boolean; + visited: boolean; + parent: Node | null; + pos: Point; + addr: [number, number]; +}; + +type Grid = { + row: number; + col: number; + data: (Node | null)[]; +}; + +const BASE_PADDING = 40; + +export const mutateElbowArrow = ( + arrow: ExcalidrawElbowArrowElement, + scene: Scene, + nextPoints: readonly Point[], + offset?: Point, + otherUpdates?: { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + }, + options?: { + changedElements?: Map; + isDragging?: boolean; + disableBinding?: boolean; + informMutation?: boolean; + }, +) => { + const elements = getAllElements(scene, options?.changedElements); + const elementsMap = getAllElementsMap(scene, options?.changedElements); + + const origStartGlobalPoint = translatePoint(nextPoints[0], [ + arrow.x + (offset ? offset[0] : 0), + arrow.y + (offset ? offset[1] : 0), + ]); + const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [ + arrow.x + (offset ? offset[0] : 0), + arrow.y + (offset ? offset[1] : 0), + ]); + + const startElement = + arrow.startBinding && + getBindableElementForId(arrow.startBinding.elementId, elementsMap); + const endElement = + arrow.endBinding && + getBindableElementForId(arrow.endBinding.elementId, elementsMap); + const hoveredStartElement = options?.isDragging + ? getHoveredElementForBinding( + tupleToCoors(origStartGlobalPoint), + elements, + elementsMap, + true, + ) + : startElement; + const hoveredEndElement = options?.isDragging + ? getHoveredElementForBinding( + tupleToCoors(origEndGlobalPoint), + elements, + elementsMap, + true, + ) + : endElement; + const startGlobalPoint = getGlobalPoint( + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + origEndGlobalPoint, + elementsMap, + startElement, + hoveredStartElement, + options?.isDragging, + ); + const endGlobalPoint = getGlobalPoint( + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + origStartGlobalPoint, + elementsMap, + endElement, + hoveredEndElement, + options?.isDragging, + ); + const startHeading = getBindPointHeading( + startGlobalPoint, + endGlobalPoint, + elementsMap, + hoveredStartElement, + origStartGlobalPoint, + ); + const endHeading = getBindPointHeading( + endGlobalPoint, + startGlobalPoint, + elementsMap, + hoveredEndElement, + origEndGlobalPoint, + ); + const startPointBounds = [ + startGlobalPoint[0] - 2, + startGlobalPoint[1] - 2, + startGlobalPoint[0] + 2, + startGlobalPoint[1] + 2, + ] as Bounds; + const endPointBounds = [ + endGlobalPoint[0] - 2, + endGlobalPoint[1] - 2, + endGlobalPoint[0] + 2, + endGlobalPoint[1] + 2, + ] as Bounds; + const startElementBounds = hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading( + startHeading, + arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : startPointBounds; + const endElementBounds = hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading( + endHeading, + arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : endPointBounds; + const boundsOverlap = + pointInsideBounds( + startGlobalPoint, + hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), + ) + : endPointBounds, + ) || + pointInsideBounds( + endGlobalPoint, + hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), + ) + : startPointBounds, + ); + const commonBounds = commonAABB( + boundsOverlap + ? [startPointBounds, endPointBounds] + : [startElementBounds, endElementBounds], + ); + const dynamicAABBs = generateDynamicAABBs( + boundsOverlap ? startPointBounds : startElementBounds, + boundsOverlap ? endPointBounds : endElementBounds, + commonBounds, + boundsOverlap + ? offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap + ? offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap, + ); + const startDonglePosition = getDonglePosition( + dynamicAABBs[0], + startHeading, + startGlobalPoint, + ); + const endDonglePosition = getDonglePosition( + dynamicAABBs[1], + endHeading, + endGlobalPoint, + ); + + // Canculate Grid positions + const grid = calculateGrid( + dynamicAABBs, + startDonglePosition ? startDonglePosition : startGlobalPoint, + startHeading, + endDonglePosition ? endDonglePosition : endGlobalPoint, + endHeading, + commonBounds, + ); + + const startDongle = + startDonglePosition && pointToGridNode(startDonglePosition, grid); + const endDongle = + endDonglePosition && pointToGridNode(endDonglePosition, grid); + + // Do not allow stepping on the true end or true start points + const endNode = pointToGridNode(endGlobalPoint, grid); + if (endNode && hoveredEndElement) { + endNode.closed = true; + } + const startNode = pointToGridNode(startGlobalPoint, grid); + if (startNode && arrow.startBinding) { + startNode.closed = true; + } + const dongleOverlap = + startDongle && + endDongle && + (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || + pointInsideBounds(endDongle.pos, dynamicAABBs[0])); + + // Create path to end dongle from start dongle + const path = astar( + startDongle ? startDongle : startNode!, + endDongle ? endDongle : endNode!, + grid, + startHeading ? startHeading : HEADING_RIGHT, + endHeading ? endHeading : HEADING_RIGHT, + dongleOverlap ? [] : dynamicAABBs, + ); + + if (path) { + const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[]; + startDongle && points.unshift(startGlobalPoint); + endDongle && points.push(endGlobalPoint); + + mutateElement( + arrow, + { + ...otherUpdates, + ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0), + angle: 0, + }, + options?.informMutation, + ); + } else { + console.error("Elbow arrow cannot find a route"); + } +}; + +const offsetFromHeading = ( + heading: Heading, + head: number, + side: number, +): [number, number, number, number] => { + switch (heading) { + case HEADING_UP: + return [head, side, side, side]; + case HEADING_RIGHT: + return [side, head, side, side]; + case HEADING_DOWN: + return [side, side, head, side]; + } + + return [side, side, side, head]; +}; + +/** + * Routing algorithm based on the A* path search algorithm. + * @see https://www.geeksforgeeks.org/a-search-algorithm/ + * + * Binary heap is used to optimize node lookup. + * See {@link calculateGrid} for the grid calculation details. + * + * Additional modifications added due to aesthetic route reasons: + * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier) + * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment + */ +const astar = ( + start: Node, + end: Node, + grid: Grid, + startHeading: Heading, + endHeading: Heading, + aabbs: Bounds[], +) => { + const bendMultiplier = m_dist(start.pos, end.pos); + const open = new BinaryHeap((node) => node.f); + + open.push(start); + + while (open.size() > 0) { + // Grab the lowest f(x) to process next. Heap keeps this sorted for us. + const current = open.pop(); + + if (!current || current.closed) { + // Current is not passable, continue with next element + continue; + } + + // End case -- result has been found, return the traced path. + if (current === end) { + return pathTo(start, current); + } + + // Normal case -- move current from open to closed, process each of its neighbors. + current.closed = true; + + // Find all neighbors for the current node. + const neighbors = getNeighbors(current.addr, grid); + + for (let i = 0; i < 4; i++) { + const neighbor = neighbors[i]; + + if (!neighbor || neighbor.closed) { + // Not a valid node to process, skip to next neighbor. + continue; + } + + // Intersect + const neighborHalfPoint = scalePointFromOrigin( + neighbor.pos, + current.pos, + 0.5, + ); + if ( + isAnyTrue( + ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)), + ) + ) { + continue; + } + + // The g score is the shortest distance from start to current node. + // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. + const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); + const previousDirection = current.parent + ? vectorToHeading(pointToVector(current.pos, current.parent.pos)) + : startHeading; + + // Do not allow going in reverse + const reverseHeading = scaleVector(previousDirection, -1); + const neighborIsReverseRoute = + arePointsEqual(reverseHeading, neighborHeading) || + (arePointsEqual(start.addr, neighbor.addr) && + arePointsEqual(neighborHeading, startHeading)) || + (arePointsEqual(end.addr, neighbor.addr) && + arePointsEqual(neighborHeading, endHeading)); + if (neighborIsReverseRoute) { + continue; + } + + const directionChange = previousDirection !== neighborHeading; + const gScore = + current.g + + m_dist(neighbor.pos, current.pos) + + (directionChange ? Math.pow(bendMultiplier, 3) : 0); + + const beenVisited = neighbor.visited; + + if (!beenVisited || gScore < neighbor.g) { + const estBendCount = estimateSegmentCount( + neighbor, + end, + neighborHeading, + endHeading, + ); + // Found an optimal (so far) path to this node. Take score for node to see how good it is. + neighbor.visited = true; + neighbor.parent = current; + neighbor.h = + m_dist(end.pos, neighbor.pos) + + estBendCount * Math.pow(bendMultiplier, 2); + neighbor.g = gScore; + neighbor.f = neighbor.g + neighbor.h; + if (!beenVisited) { + // Pushing to heap will put it in proper place based on the 'f' value. + open.push(neighbor); + } else { + // Already seen the node, but since it has been rescored we need to reorder it in the heap + open.rescoreElement(neighbor); + } + } + } + } + + return null; +}; + +const pathTo = (start: Node, node: Node) => { + let curr = node; + const path = []; + while (curr.parent) { + path.unshift(curr); + curr = curr.parent; + } + path.unshift(start); + + return path; +}; + +const m_dist = (a: Point, b: Point) => + Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); + +/** + * Create dynamically resizing, always touching + * bounding boxes having a minimum extent represented + * by the given static bounds. + */ +const generateDynamicAABBs = ( + a: Bounds, + b: Bounds, + common: Bounds, + startDifference?: [number, number, number, number], + endDifference?: [number, number, number, number], + disableSideHack?: boolean, +): Bounds[] => { + const [startUp, startRight, startDown, startLeft] = startDifference ?? [ + 0, 0, 0, 0, + ]; + const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0]; + + const first = [ + a[0] > b[2] + ? a[1] > b[3] || a[3] < b[1] + ? Math.min((a[0] + b[2]) / 2, a[0] - startLeft) + : (a[0] + b[2]) / 2 + : a[0] > b[0] + ? a[0] - startLeft + : common[0] - startLeft, + a[1] > b[3] + ? a[0] > b[2] || a[2] < b[0] + ? Math.min((a[1] + b[3]) / 2, a[1] - startUp) + : (a[1] + b[3]) / 2 + : a[1] > b[1] + ? a[1] - startUp + : common[1] - startUp, + a[2] < b[0] + ? a[1] > b[3] || a[3] < b[1] + ? Math.max((a[2] + b[0]) / 2, a[2] + startRight) + : (a[2] + b[0]) / 2 + : a[2] < b[2] + ? a[2] + startRight + : common[2] + startRight, + a[3] < b[1] + ? a[0] > b[2] || a[2] < b[0] + ? Math.max((a[3] + b[1]) / 2, a[3] + startDown) + : (a[3] + b[1]) / 2 + : a[3] < b[3] + ? a[3] + startDown + : common[3] + startDown, + ] as Bounds; + const second = [ + b[0] > a[2] + ? b[1] > a[3] || b[3] < a[1] + ? Math.min((b[0] + a[2]) / 2, b[0] - endLeft) + : (b[0] + a[2]) / 2 + : b[0] > a[0] + ? b[0] - endLeft + : common[0] - endLeft, + b[1] > a[3] + ? b[0] > a[2] || b[2] < a[0] + ? Math.min((b[1] + a[3]) / 2, b[1] - endUp) + : (b[1] + a[3]) / 2 + : b[1] > a[1] + ? b[1] - endUp + : common[1] - endUp, + b[2] < a[0] + ? b[1] > a[3] || b[3] < a[1] + ? Math.max((b[2] + a[0]) / 2, b[2] + endRight) + : (b[2] + a[0]) / 2 + : b[2] < a[2] + ? b[2] + endRight + : common[2] + endRight, + b[3] < a[1] + ? b[0] > a[2] || b[2] < a[0] + ? Math.max((b[3] + a[1]) / 2, b[3] + endDown) + : (b[3] + a[1]) / 2 + : b[3] < a[3] + ? b[3] + endDown + : common[3] + endDown, + ] as Bounds; + + const c = commonAABB([first, second]); + if ( + !disableSideHack && + first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 && + first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001 + ) { + const [endCenterX, endCenterY] = [ + (second[0] + second[2]) / 2, + (second[1] + second[3]) / 2, + ]; + if (b[0] > a[2] && a[1] > b[3]) { + // BOTTOM LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } else if (a[2] < b[0] && a[3] < b[1]) { + // TOP LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } + + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[3] < b[1]) { + // TOP RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[1] > b[3]) { + // BOTTOM RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } + } + + return [first, second]; +}; + +/** + * Calculates the grid which is used as nodes at + * the grid line intersections by the A* algorithm. + * + * NOTE: This is not a uniform grid. It is built at + * various intersections of bounding boxes. + */ +const calculateGrid = ( + aabbs: Bounds[], + start: Point, + startHeading: Heading, + end: Point, + endHeading: Heading, + common: Bounds, +): Grid => { + const horizontal = new Set(); + const vertical = new Set(); + + if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) { + vertical.add(start[1]); + } else { + horizontal.add(start[0]); + } + if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) { + vertical.add(end[1]); + } else { + horizontal.add(end[0]); + } + + aabbs.forEach((aabb) => { + horizontal.add(aabb[0]); + horizontal.add(aabb[2]); + vertical.add(aabb[1]); + vertical.add(aabb[3]); + }); + + horizontal.add(common[0]); + horizontal.add(common[2]); + vertical.add(common[1]); + vertical.add(common[3]); + + const _vertical = Array.from(vertical).sort((a, b) => a - b); + const _horizontal = Array.from(horizontal).sort((a, b) => a - b); + + return { + row: _vertical.length, + col: _horizontal.length, + data: _vertical.flatMap((y, row) => + _horizontal.map( + (x, col): Node => ({ + f: 0, + g: 0, + h: 0, + closed: false, + visited: false, + parent: null, + addr: [col, row] as [number, number], + pos: [x, y] as Point, + }), + ), + ), + }; +}; + +const getDonglePosition = ( + bounds: Bounds, + heading: Heading, + point: Point, +): Point => { + switch (heading) { + case HEADING_UP: + return [point[0], bounds[1]]; + case HEADING_RIGHT: + return [bounds[2], point[1]]; + case HEADING_DOWN: + return [point[0], bounds[3]]; + } + return [bounds[0], point[1]]; +}; + +const estimateSegmentCount = ( + start: Node, + end: Node, + startHeading: Heading, + endHeading: Heading, +) => { + if (endHeading === HEADING_RIGHT) { + switch (startHeading) { + case HEADING_RIGHT: { + if (start.pos[0] >= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + } + } else if (endHeading === HEADING_LEFT) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[0] <= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + } else if (endHeading === HEADING_UP) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[1] >= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_DOWN: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } else if (endHeading === HEADING_DOWN) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_DOWN: + if (start.pos[1] <= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } + return 0; +}; + +/** + * Get neighboring points for a gived grid address + */ +const getNeighbors = ([col, row]: [number, number], grid: Grid) => + [ + gridNodeFromAddr([col, row - 1], grid), + gridNodeFromAddr([col + 1, row], grid), + gridNodeFromAddr([col, row + 1], grid), + gridNodeFromAddr([col - 1, row], grid), + ] as [Node | null, Node | null, Node | null, Node | null]; + +const gridNodeFromAddr = ( + [col, row]: [col: number, row: number], + grid: Grid, +): Node | null => { + if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) { + return null; + } + + return grid.data[row * grid.col + col] ?? null; +}; + +/** + * Get node for global point on canvas (if exists) + */ +const pointToGridNode = (point: Point, grid: Grid): Node | null => { + for (let col = 0; col < grid.col; col++) { + for (let row = 0; row < grid.row; row++) { + const candidate = gridNodeFromAddr([col, row], grid); + if ( + candidate && + point[0] === candidate.pos[0] && + point[1] === candidate.pos[1] + ) { + return candidate; + } + } + } + + return null; +}; + +const commonAABB = (aabbs: Bounds[]): Bounds => [ + Math.min(...aabbs.map((aabb) => aabb[0])), + Math.min(...aabbs.map((aabb) => aabb[1])), + Math.max(...aabbs.map((aabb) => aabb[2])), + Math.max(...aabbs.map((aabb) => aabb[3])), +]; + +/// #region Utils + +const getBindableElementForId = ( + id: string, + elementsMap: ElementsMap, +): ExcalidrawBindableElement | null => { + const element = elementsMap.get(id); + if (element && isBindableElement(element)) { + return element; + } + + return null; +}; + +const normalizedArrowElementUpdate = ( + global: Point[], + externalOffsetX?: number, + externalOffsetY?: number, +) => { + const offsetX = global[0][0]; + const offsetY = global[0][1]; + + const points = global.map( + (point, _idx) => [point[0] - offsetX, point[1] - offsetY] as const, + ); + + return { + points, + x: offsetX + (externalOffsetX ?? 0), + y: offsetY + (externalOffsetY ?? 0), + ...getSizeFromPoints(points), + }; +}; + +/// If last and current segments have the same heading, skip the middle point +const simplifyElbowArrowPoints = (points: Point[]): Point[] => + points + .slice(2) + .reduce( + (result, point) => + arePointsEqual( + vectorToHeading( + pointToVector(result[result.length - 1], result[result.length - 2]), + ), + vectorToHeading(pointToVector(point, result[result.length - 1])), + ) + ? [...result.slice(0, -1), point] + : [...result, point], + [points[0] ?? [0, 0], points[1] ?? [1, 0]], + ); + +const neighborIndexToHeading = (idx: number): Heading => { + switch (idx) { + case 0: + return HEADING_UP; + case 1: + return HEADING_RIGHT; + case 2: + return HEADING_DOWN; + } + return HEADING_LEFT; +}; + +const getAllElementsMap = ( + scene: Scene, + changedElements?: Map, +): NonDeletedSceneElementsMap => + changedElements + ? toBrandedType( + new Map([...scene.getNonDeletedElementsMap(), ...changedElements]), + ) + : scene.getNonDeletedElementsMap(); + +const getAllElements = ( + scene: Scene, + changedElements?: Map, +): readonly NonDeletedExcalidrawElement[] => + changedElements + ? ([ + ...scene.getNonDeletedElements(), + ...[...changedElements].map(([_, value]) => value), + ] as NonDeletedExcalidrawElement[]) + : scene.getNonDeletedElements(); + +const getGlobalPoint = ( + fixedPointRatio: [number, number] | undefined | null, + initialPoint: Point, + otherPoint: Point, + elementsMap: NonDeletedSceneElementsMap, + boundElement?: ExcalidrawBindableElement | null, + hoveredElement?: ExcalidrawBindableElement | null, + isDragging?: boolean, +): Point => { + if (isDragging) { + if (hoveredElement) { + const snapPoint = getSnapPoint( + initialPoint, + otherPoint, + hoveredElement, + elementsMap, + ); + + return snapToMid(hoveredElement, snapPoint); + } + + return initialPoint; + } + + if (boundElement) { + const fixedGlobalPoint = getGlobalFixedPointForBindableElement( + fixedPointRatio || [0, 0], + boundElement, + ); + + // NOTE: Resize scales the binding position point too, so we need to update it + return Math.abs( + distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) - + FIXED_BINDING_DISTANCE, + ) > 0.01 + ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap) + : fixedGlobalPoint; + } + + return initialPoint; +}; + +const getSnapPoint = ( + point: Point, + otherPoint: Point, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => + bindPointToSnapToElementOutline( + isRectanguloidElement(element) + ? avoidRectangularCorner(element, point) + : point, + otherPoint, + element, + elementsMap, + ); + +const getBindPointHeading = ( + point: Point, + otherPoint: Point, + elementsMap: NonDeletedSceneElementsMap, + hoveredElement: ExcalidrawBindableElement | null | undefined, + origPoint: Point, +) => + getHeadingForElbowArrowSnap( + point, + otherPoint, + hoveredElement, + hoveredElement && + aabbForElement( + hoveredElement, + Array(4).fill( + distanceToBindableElement(hoveredElement, point, elementsMap), + ) as [number, number, number, number], + ), + elementsMap, + origPoint, + ); diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 0b642b274d..e1ffd487fa 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -9,7 +9,11 @@ import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; -import { isFrameLikeElement, isLinearElement } from "./typeChecks"; +import { + isElbowArrow, + isFrameLikeElement, + isLinearElement, +} from "./typeChecks"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, isAndroid, @@ -262,7 +266,11 @@ export const getTransformHandles = ( // so that when locked element is selected (especially when you toggle lock // via keyboard) the locked element is visually distinct, indicating // you can't move/resize - if (element.locked) { + if ( + element.locked || + // Elbow arrows cannot be rotated + isElbowArrow(element) + ) { return {}; } @@ -312,6 +320,9 @@ export const shouldShowBoundingBox = ( return true; } const element = elements[0]; + if (isElbowArrow(element)) { + return false; + } if (!isLinearElement(element)) { return true; } diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 347f41ce66..17eaaad54e 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -21,6 +21,9 @@ import type { ExcalidrawIframeLikeElement, ExcalidrawMagicFrameElement, ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, + PointBinding, + FixedPointBinding, } from "./types"; export const isInitializedImageElement = ( @@ -106,6 +109,12 @@ export const isArrowElement = ( return element != null && element.type === "arrow"; }; +export const isElbowArrow = ( + element?: ExcalidrawElement, +): element is ExcalidrawElbowArrowElement => { + return isArrowElement(element) && element.elbowed; +}; + export const isLinearElementType = ( elementType: ElementOrToolType, ): boolean => { @@ -150,6 +159,22 @@ export const isBindableElement = ( ); }; +export const isRectanguloidElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawBindableElement => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "image" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + (element.type === "text" && !element.containerId)) + ); +}; + export const isTextBindableContainer = ( element: ExcalidrawElement | null, includeLocked = true, @@ -263,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = ( return null; }; + +export const isFixedPointBinding = ( + binding: PointBinding, +): binding is FixedPointBinding => { + return binding.fixedPoint != null; +}; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 700b7ed6c4..52e8104823 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -6,7 +6,12 @@ import type { THEME, VERTICAL_ALIGN, } from "../constants"; -import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types"; +import type { + MakeBrand, + MarkNonNullable, + Merge, + ValueOf, +} from "../utility-types"; import type { MagicCacheData } from "../data/magic"; export type ChartType = "bar" | "line"; @@ -228,12 +233,22 @@ export type ExcalidrawTextElementWithContainer = { containerId: ExcalidrawTextContainer["id"]; } & ExcalidrawTextElement; +export type FixedPoint = [number, number]; + export type PointBinding = { elementId: ExcalidrawBindableElement["id"]; focus: number; gap: number; + // Represents the fixed point binding information in form of a vertical and + // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio + // gives the user selected fixed point by multiplying the bound element width + // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the + // bound element-local point coordinate. + fixedPoint: FixedPoint | null; }; +export type FixedPointBinding = Merge; + export type Arrowhead = | "arrow" | "bar" @@ -259,8 +274,18 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawArrowElement = ExcalidrawLinearElement & Readonly<{ type: "arrow"; + elbowed: boolean; }>; +export type ExcalidrawElbowArrowElement = Merge< + ExcalidrawArrowElement, + { + elbowed: true; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + } +>; + export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index daed2a3940..ea76df9b1f 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -1,6 +1,7 @@ import type { AppStateChange, ElementsChange } from "./change"; import type { SceneElementsMap } from "./element/types"; import { Emitter } from "./emitter"; +import type Scene from "./scene/Scene"; import type { Snapshot } from "./store"; import type { AppState } from "./types"; @@ -64,6 +65,7 @@ export class History { elements: SceneElementsMap, appState: AppState, snapshot: Readonly, + scene: Scene, ) { return this.perform( elements, @@ -71,6 +73,7 @@ export class History { snapshot, () => History.pop(this.undoStack), (entry: HistoryEntry) => History.push(this.redoStack, entry, elements), + scene, ); } @@ -78,6 +81,7 @@ export class History { elements: SceneElementsMap, appState: AppState, snapshot: Readonly, + scene: Scene, ) { return this.perform( elements, @@ -85,6 +89,7 @@ export class History { snapshot, () => History.pop(this.redoStack), (entry: HistoryEntry) => History.push(this.undoStack, entry, elements), + scene, ); } @@ -94,6 +99,7 @@ export class History { snapshot: Readonly, pop: () => HistoryEntry | null, push: (entry: HistoryEntry) => void, + scene: Scene, ): [SceneElementsMap, AppState] | void { try { let historyEntry = pop(); @@ -110,7 +116,7 @@ export class History { while (historyEntry) { try { [nextElements, nextAppState, containsVisibleChange] = - historyEntry.applyTo(nextElements, nextAppState, snapshot); + historyEntry.applyTo(nextElements, nextAppState, snapshot, scene); } finally { // make sure to always push / pop, even if the increment is corrupted push(historyEntry); @@ -181,9 +187,10 @@ export class HistoryEntry { elements: SceneElementsMap, appState: AppState, snapshot: Readonly, + scene: Scene, ): [SceneElementsMap, AppState, boolean] { const [nextElements, elementsContainVisibleChange] = - this.elementsChange.applyTo(elements, snapshot.elements); + this.elementsChange.applyTo(elements, snapshot.elements, scene); const [nextAppState, appStateContainsVisibleChange] = this.appStateChange.applyTo(appState, nextElements); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 2fe4418105..afb213df1f 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -46,6 +46,10 @@ "arrowhead_triangle_outline": "Triangle (outline)", "arrowhead_diamond": "Diamond", "arrowhead_diamond_outline": "Diamond (outline)", + "arrowtypes": "Arrow type", + "arrowtype_sharp": "Sharp arrow", + "arrowtype_round": "Curved arrow", + "arrowtype_elbowed": "Elbow arrow", "fontSize": "Font size", "fontFamily": "Font family", "addWatermark": "Add \"Made with Excalidraw\"", @@ -295,6 +299,7 @@ "hints": { "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool", "linearElement": "Click to start multiple points, drag for single line", + "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.", "freeDraw": "Click and drag, release when you're finished", "text": "Tip: you can also add text by double-clicking anywhere with the selection tool", "embeddable": "Click-drag to create a website embed", diff --git a/packages/excalidraw/math.test.ts b/packages/excalidraw/math.test.ts index eb5392eed2..0d23428380 100644 --- a/packages/excalidraw/math.test.ts +++ b/packages/excalidraw/math.test.ts @@ -1,4 +1,9 @@ -import { rangeIntersection, rangesOverlap, rotate } from "./math"; +import { + isPointOnSymmetricArc, + rangeIntersection, + rangesOverlap, + rotate, +} from "./math"; describe("rotate", () => { it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { @@ -53,3 +58,42 @@ describe("range intersection", () => { expect(rangeIntersection([1, 4], [5, 7])).toEqual(null); }); }); + +describe("point on arc", () => { + it("should detect point on simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + [0.92291667, 0.385], + ), + ).toBe(true); + }); + it("should not detect point outside of a simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + [-0.92291667, 0.385], + ), + ).toBe(false); + }); + it("should not detect point with good angle but incorrect radius", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + [-0.5, 0.5], + ), + ).toBe(false); + }); +}); diff --git a/packages/excalidraw/math.ts b/packages/excalidraw/math.ts index d84ee7e067..0f84ce98ae 100644 --- a/packages/excalidraw/math.ts +++ b/packages/excalidraw/math.ts @@ -10,9 +10,11 @@ import type { ExcalidrawLinearElement, NonDeleted, } from "./element/types"; +import type { Bounds } from "./element/bounds"; import { getCurvePathOps } from "./element/bounds"; import type { Mutable } from "./utility-types"; import { ShapeCache } from "./scene/ShapeCache"; +import type { Vector } from "../utils/geometry/shape"; export const rotate = ( // target point to rotate @@ -153,6 +155,12 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { return Math.hypot(xd, yd); }; +export const distanceSq2d = (p1: Point, p2: Point) => { + const xd = p2[0] - p1[0]; + const yd = p2[1] - p1[1]; + return xd * xd + yd * yd; +}; + export const centerPoint = (a: Point, b: Point): Point => { return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; }; @@ -519,3 +527,179 @@ export const rangeIntersection = ( export const isValueInRange = (value: number, min: number, max: number) => { return value >= min && value <= max; }; + +export const translatePoint = (p: Point, v: Vector): Point => [ + p[0] + v[0], + p[1] + v[1], +]; + +export const scaleVector = (v: Vector, scalar: number): Vector => [ + v[0] * scalar, + v[1] * scalar, +]; + +export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [ + p[0] - origin[0], + p[1] - origin[1], +]; + +export const scalePointFromOrigin = ( + p: Point, + mid: Point, + multiplier: number, +) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier)); + +const triangleSign = (p1: Point, p2: Point, p3: Point): number => + (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]); + +export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => { + const d1 = triangleSign(pt, v1, v2); + const d2 = triangleSign(pt, v2, v3); + const d3 = triangleSign(pt, v3, v1); + + const has_neg = d1 < 0 || d2 < 0 || d3 < 0; + const has_pos = d1 > 0 || d2 > 0 || d3 > 0; + + return !(has_neg && has_pos); +}; + +export const magnitudeSq = (vector: Vector) => + vector[0] * vector[0] + vector[1] * vector[1]; + +export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector)); + +export const normalize = (vector: Vector): Vector => { + const m = magnitude(vector); + + return [vector[0] / m, vector[1] / m]; +}; + +export const addVectors = ( + vec1: Readonly, + vec2: Readonly, +): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]]; + +export const subtractVectors = ( + vec1: Readonly, + vec2: Readonly, +): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]]; + +export const pointInsideBounds = (p: Point, bounds: Bounds): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; + +/** + * Get the axis-aligned bounding box for a given element + */ +export const aabbForElement = ( + element: Readonly, + offset?: [number, number, number, number], +) => { + const bbox = { + minX: element.x, + minY: element.y, + maxX: element.x + element.width, + maxY: element.y + element.height, + midX: element.x + element.width / 2, + midY: element.y + element.height / 2, + }; + + const center = [bbox.midX, bbox.midY] as Point; + const [topLeftX, topLeftY] = rotatePoint( + [bbox.minX, bbox.minY], + center, + element.angle, + ); + const [topRightX, topRightY] = rotatePoint( + [bbox.maxX, bbox.minY], + center, + element.angle, + ); + const [bottomRightX, bottomRightY] = rotatePoint( + [bbox.maxX, bbox.maxY], + center, + element.angle, + ); + const [bottomLeftX, bottomLeftY] = rotatePoint( + [bbox.minX, bbox.maxY], + center, + element.angle, + ); + + const bounds = [ + Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), + Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), + ] as Bounds; + + if (offset) { + const [topOffset, rightOffset, downOffset, leftOffset] = offset; + return [ + bounds[0] - leftOffset, + bounds[1] - topOffset, + bounds[2] + rightOffset, + bounds[3] + downOffset, + ] as Bounds; + } + + return bounds; +}; + +type PolarCoords = [number, number]; + +/** + * Return the polar coordinates for the given carthesian point represented by + * (x, y) for the center point 0,0 where the first number returned is the radius, + * the second is the angle in radians. + */ +export const carthesian2Polar = ([x, y]: Point): PolarCoords => [ + Math.hypot(x, y), + Math.atan2(y, x), +]; + +/** + * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle + * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right". + */ +type SymmetricArc = { radius: number; startAngle: number; endAngle: number }; + +/** + * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which + * is part of a circle contour centered on 0, 0. + */ +export const isPointOnSymmetricArc = ( + { radius: arcRadius, startAngle, endAngle }: SymmetricArc, + point: Point, +): boolean => { + const [radius, angle] = carthesian2Polar(point); + + return startAngle < endAngle + ? Math.abs(radius - arcRadius) < 0.0000001 && + startAngle <= angle && + endAngle >= angle + : startAngle <= angle || endAngle >= angle; +}; + +export const getCenterForBounds = (bounds: Bounds): Point => [ + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, +]; + +export const getCenterForElement = (element: ExcalidrawElement): Point => [ + element.x + element.width / 2, + element.y + element.height / 2, +]; + +export const aabbsOverlapping = (a: Bounds, b: Bounds) => + pointInsideBounds([a[0], a[1]], b) || + pointInsideBounds([a[2], a[1]], b) || + pointInsideBounds([a[2], a[3]], b) || + pointInsideBounds([a[0], a[3]], b) || + pointInsideBounds([b[0], b[1]], a) || + pointInsideBounds([b[2], b[1]], a) || + pointInsideBounds([b[2], b[3]], a) || + pointInsideBounds([b[0], b[3]], a); + +export const clamp = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index d6b27e72db..ab37a14256 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -48,6 +48,8 @@ import { } from "./helpers"; import oc from "open-color"; import { + isArrowElement, + isElbowArrow, isFrameLikeElement, isLinearElement, isTextElement, @@ -67,6 +69,7 @@ import type { InteractiveSceneRenderConfig, RenderableElementsMap, } from "../scene/types"; +import { getCornerRadius } from "../math"; const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, @@ -212,13 +215,18 @@ const renderBindingHighlightForBindableElement = ( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const width = x2 - x1; const height = y2 - y1; - const threshold = maxBindingGap(element, width, height); + const thickness = 10; // 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 / 2 + threshold / 2; + context.lineWidth = thickness - strokeOffset; + const padding = strokeOffset / 2 + thickness / 2; + + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); switch (element.type) { case "rectangle": @@ -237,6 +245,8 @@ const renderBindingHighlightForBindableElement = ( x1 + width / 2, y1 + height / 2, element.angle, + undefined, + radius, ); break; case "diamond": @@ -474,6 +484,10 @@ const renderLinearPointHandles = ( ? POINT_HANDLE_SIZE : POINT_HANDLE_SIZE / 2; points.forEach((point, idx) => { + if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) { + return; + } + const isSelected = !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); @@ -727,7 +741,13 @@ const _renderInteractiveScene = ({ if ( appState.selectedLinearElement && - appState.selectedLinearElement.hoverPointIndex >= 0 + appState.selectedLinearElement.hoverPointIndex >= 0 && + !( + isElbowArrow(selectedElements[0]) && + appState.selectedLinearElement.hoverPointIndex > 0 && + appState.selectedLinearElement.hoverPointIndex < + selectedElements[0].points.length - 1 + ) ) { renderLinearElementPointHighlight(context, appState, elementsMap); } @@ -771,27 +791,39 @@ const _renderInteractiveScene = ({ for (const element of elementsMap.values()) { const selectionColors = []; - // local user - if ( - locallySelectedIds.has(element.id) && - !isSelectedViaGroup(appState, element) - ) { - selectionColors.push(selectionColor); - } - // remote users const remoteClients = renderConfig.remoteSelectedElementIds.get( element.id, ); - if (remoteClients) { - selectionColors.push( - ...remoteClients.map((socketId) => { - const background = getClientColor( - socketId, - appState.collaborators.get(socketId), - ); - return background; - }), - ); + if ( + !( + // Elbow arrow elements cannot be selected when bound on either end + ( + isSingleLinearElementSelected && + isArrowElement(element) && + isElbowArrow(element) && + (element.startBinding || element.endBinding) + ) + ) + ) { + // local user + if ( + locallySelectedIds.has(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(selectionColor); + } + // remote users + if (remoteClients) { + selectionColors.push( + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), + ); + } } if (selectionColors.length) { diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index ccebe867e0..4bc92f9c7e 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -9,12 +9,13 @@ import type { ExcalidrawLinearElement, Arrowhead, } from "../element/types"; -import { isPathALoop, getCornerRadius } from "../math"; +import { isPathALoop, getCornerRadius, distanceSq2d } from "../math"; import { generateFreeDrawShape } from "../renderer/renderElement"; import { isTransparent, assertNever } from "../utils"; import { simplify } from "points-on-curve"; import { ROUGHNESS } from "../constants"; import { + isElbowArrow, isEmbeddableElement, isIframeElement, isIframeLikeElement, @@ -400,9 +401,16 @@ export const _generateElementShape = ( // initial position to it const points = element.points.length ? element.points : [[0, 0]]; - // curve is always the first element - // this simplifies finding the curve for an element - if (!element.roundness) { + if (isElbowArrow(element)) { + shape = [ + generator.path( + generateElbowArrowShape(points as [number, number][], 16), + generateRoughOptions(element, true), + ), + ]; + } else if (!element.roundness) { + // curve is always the first element + // this simplifies finding the curve for an element if (options.fill) { shape = [generator.polygon(points as [number, number][], options)]; } else { @@ -482,3 +490,60 @@ export const _generateElementShape = ( } } }; + +const generateElbowArrowShape = ( + points: [number, number][], + radius: number, +) => { + const subpoints = [] as [number, number][]; + for (let i = 1; i < points.length - 1; i += 1) { + const prev = points[i - 1]; + const next = points[i + 1]; + const corner = Math.min( + radius, + Math.sqrt(distanceSq2d(points[i], next)) / 2, + Math.sqrt(distanceSq2d(points[i], prev)) / 2, + ); + + if (prev[0] < points[i][0] && prev[1] === points[i][1]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else if (prev[0] === points[i][0] && prev[1] < points[i][1]) { + // UP + subpoints.push([points[i][0], points[i][1] - corner]); + } else if (prev[0] > points[i][0] && prev[1] === points[i][1]) { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } else { + subpoints.push([points[i][0], points[i][1] + corner]); + } + + subpoints.push(points[i] as [number, number]); + + if (next[0] < points[i][0] && next[1] === points[i][1]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else if (next[0] === points[i][0] && next[1] < points[i][1]) { + // UP + subpoints.push([points[i][0], points[i][1] - corner]); + } else if (next[0] > points[i][0] && next[1] === points[i][1]) { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } else { + subpoints.push([points[i][0], points[i][1] + corner]); + } + } + + const d = [`M ${points[0][0]} ${points[0][1]}`]; + for (let i = 0; i < subpoints.length; i += 3) { + d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`); + d.push( + `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${ + subpoints[i + 2][0] + } ${subpoints[i + 2][1]}`, + ); + } + d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`); + + return d.join(" "); +}; diff --git a/packages/excalidraw/scene/comparisons.ts b/packages/excalidraw/scene/comparisons.ts index d46897eb59..629fb371eb 100644 --- a/packages/excalidraw/scene/comparisons.ts +++ b/packages/excalidraw/scene/comparisons.ts @@ -40,11 +40,12 @@ export const canChangeRoundness = (type: ElementOrToolType) => type === "rectangle" || type === "iframe" || type === "embeddable" || - type === "arrow" || type === "line" || type === "diamond" || type === "image"; +export const toolIsArrow = (type: ElementOrToolType) => type === "arrow"; + export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; export const getElementAtPosition = ( diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 44606feb1d..efa1e3ed02 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -796,6 +796,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -998,6 +999,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1210,6 +1212,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1537,6 +1540,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1864,6 +1868,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2076,6 +2081,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2312,6 +2318,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2609,6 +2616,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2974,6 +2982,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "cross-hatch", @@ -3445,6 +3454,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3764,6 +3774,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4083,6 +4094,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5265,6 +5277,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -6388,6 +6401,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7319,6 +7333,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8227,6 +8242,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9117,6 +9133,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index ab705360fd..74330e6e5f 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -8,6 +8,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 8786b60237..8b49fbe9eb 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -13,6 +13,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -72,13 +73,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id163": true, + "id166": true, }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id163": true, + "id166": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -116,7 +117,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id161", + "id": "id164", "index": "a0", "isDeleted": false, "link": null, @@ -148,7 +149,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id162", + "id": "id165", "index": "a1", "isDeleted": false, "link": null, @@ -176,9 +177,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { - "elementId": "id166", + "elementId": "id169", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, @@ -186,7 +192,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": "99.19726", - "id": "id163", + "id": "id166", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, @@ -214,7 +220,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 54, + "version": 40, "width": "98.40368", "x": 1, "y": 0, @@ -227,7 +233,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id163", + "id": "id166", "type": "arrow", }, ], @@ -236,7 +242,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 50, - "id": "id166", + "id": "id169", "index": "a3", "isDeleted": false, "link": null, @@ -278,14 +284,15 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id163" => Delta { + "id166" => Delta { "deleted": { "endBinding": { - "elementId": "id162", + "elementId": "id165", + "fixedPoint": null, "focus": "0.00990", "gap": 1, }, - "height": "0.98000", + "height": "0.98017", "points": [ [ 0, @@ -293,22 +300,24 @@ History { ], [ 98, - "-0.98000", + "-0.98017", ], ], "startBinding": { - "elementId": "id161", + "elementId": "id164", + "fixedPoint": null, "focus": "0.02970", "gap": 1, }, }, "inserted": { "endBinding": { - "elementId": "id162", + "elementId": "id165", + "fixedPoint": null, "focus": "-0.02000", "gap": 1, }, - "height": "0.00025", + "height": "0.00169", "points": [ [ 0, @@ -316,11 +325,12 @@ History { ], [ 98, - "0.00025", + "0.00169", ], ], "startBinding": { - "elementId": "id161", + "elementId": "id164", + "fixedPoint": null, "focus": "0.02000", "gap": 1, }, @@ -340,36 +350,40 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id161" => Delta { + "id164" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id163", + "id": "id166", "type": "arrow", }, ], }, }, - "id162" => Delta { + "id165" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id163", + "id": "id166", "type": "arrow", }, ], }, }, - "id163" => Delta { + "id166" => Delta { "deleted": { "endBinding": { - "elementId": "id166", + "elementId": "id169", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, @@ -389,11 +403,12 @@ History { }, "inserted": { "endBinding": { - "elementId": "id162", + "elementId": "id165", + "fixedPoint": null, "focus": "0.00990", "gap": 1, }, - "height": "0.98024", + "height": "0.98161", "points": [ [ 0, @@ -401,22 +416,23 @@ History { ], [ 98, - "-0.98024", + "-0.98161", ], ], "startBinding": { - "elementId": "id161", + "elementId": "id164", + "fixedPoint": null, "focus": "0.02970", "gap": 1, }, - "y": "0.99037", + "y": "0.99245", }, }, - "id166" => Delta { + "id169" => Delta { "deleted": { "boundElements": [ { - "id": "id163", + "id": "id166", "type": "arrow", }, ], @@ -440,7 +456,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id161" => Delta { + "id164" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -471,7 +487,7 @@ History { "isDeleted": true, }, }, - "id162" => Delta { + "id165" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -511,9 +527,9 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id163": true, + "id166": true, }, - "selectedLinearElementId": "id163", + "selectedLinearElementId": "id166", }, "inserted": { "selectedElementIds": {}, @@ -524,12 +540,13 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id163" => Delta { + "id166" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -595,6 +612,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -654,13 +672,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id158": true, + "id161": true, }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id158": true, + "id161": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -698,7 +716,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id156", + "id": "id159", "index": "a0", "isDeleted": false, "link": null, @@ -730,7 +748,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id157", + "id": "id160", "index": "a1", "isDeleted": false, "link": null, @@ -758,13 +776,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 0, - "id": "id158", + "id": "id161", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, @@ -792,7 +811,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 34, + "version": 30, "width": 0, "x": 251, "y": 0, @@ -819,7 +838,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id158" => Delta { + "id161" => Delta { "deleted": { "points": [ [ @@ -859,33 +878,33 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id156" => Delta { + "id159" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id158", + "id": "id161", "type": "arrow", }, ], }, }, - "id157" => Delta { + "id160" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id158", + "id": "id161", "type": "arrow", }, ], }, }, - "id158" => Delta { + "id161" => Delta { "deleted": { "endBinding": null, "points": [ @@ -902,7 +921,8 @@ History { }, "inserted": { "endBinding": { - "elementId": "id157", + "elementId": "id160", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -917,7 +937,8 @@ History { ], ], "startBinding": { - "elementId": "id156", + "elementId": "id159", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -938,7 +959,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id156" => Delta { + "id159" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -969,7 +990,7 @@ History { "isDeleted": true, }, }, - "id157" => Delta { + "id160" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -1009,9 +1030,9 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id158": true, + "id161": true, }, - "selectedLinearElementId": "id158", + "selectedLinearElementId": "id161", }, "inserted": { "selectedElementIds": {}, @@ -1022,12 +1043,13 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id158" => Delta { + "id161" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1093,6 +1115,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1191,17 +1214,22 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": null, "endBinding": { - "elementId": "id168", + "elementId": "id171", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.03596", - "id": "id169", + "height": "2.61991", + "id": "id172", "index": "Zz", "isDeleted": false, "lastCommittedPoint": null, @@ -1215,7 +1243,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 98, - "-0.03596", + "-2.61991", ], ], "roughness": 1, @@ -1224,7 +1252,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "startArrowhead": null, "startBinding": { - "elementId": "id167", + "elementId": "id170", + "fixedPoint": [ + 1, + "0.50000", + ], "focus": 0, "gap": 1, }, @@ -1233,10 +1265,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 11, "width": 98, "x": 1, - "y": "0.05467", + "y": "3.98333", } `; @@ -1246,7 +1278,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id169", + "id": "id172", "type": "arrow", }, ], @@ -1255,7 +1287,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id167", + "id": "id170", "index": "a0", "isDeleted": false, "link": null, @@ -1283,7 +1315,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id169", + "id": "id172", "type": "arrow", }, ], @@ -1292,7 +1324,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id168", + "id": "id171", "index": "a1", "isDeleted": false, "link": null, @@ -1334,7 +1366,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id167" => Delta { + "id170" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -1365,7 +1397,7 @@ History { "isDeleted": true, }, }, - "id168" => Delta { + "id171" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -1398,15 +1430,23 @@ History { }, }, "updated": Map { - "id169" => Delta { + "id172" => Delta { "deleted": { "endBinding": { - "elementId": "id168", + "elementId": "id171", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, "startBinding": { - "elementId": "id167", + "elementId": "id170", + "fixedPoint": [ + 1, + "0.50000", + ], "focus": 0, "gap": 1, }, @@ -1440,6 +1480,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1538,17 +1579,22 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": null, "endBinding": { - "elementId": "id171", + "elementId": "id174", + "fixedPoint": [ + 1, + "0.50000", + ], "focus": 0, "gap": 1, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.03596", - "id": "id172", + "height": "2.61991", + "id": "id175", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1562,7 +1608,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 98, - "-0.03596", + "-2.61991", ], ], "roughness": 1, @@ -1571,7 +1617,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "startArrowhead": null, "startBinding": { - "elementId": "id170", + "elementId": "id173", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, @@ -1580,10 +1630,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 11, "width": 98, "x": 1, - "y": "0.05467", + "y": "3.98333", } `; @@ -1593,7 +1643,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id172", + "id": "id175", "type": "arrow", }, ], @@ -1602,7 +1652,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id170", + "id": "id173", "index": "a0V", "isDeleted": false, "link": null, @@ -1630,7 +1680,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id172", + "id": "id175", "type": "arrow", }, ], @@ -1639,7 +1689,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id171", + "id": "id174", "index": "a1", "isDeleted": false, "link": null, @@ -1681,22 +1731,27 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id172" => Delta { + "id175" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": null, "endBinding": { - "elementId": "id171", + "elementId": "id174", + "fixedPoint": [ + 1, + "0.50000", + ], "focus": 0, "gap": 1, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "2.61991", + "height": "22.36242", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1710,7 +1765,7 @@ History { ], [ 98, - "-2.61991", + "-22.36242", ], ], "roughness": 1, @@ -1719,7 +1774,11 @@ History { }, "startArrowhead": null, "startBinding": { - "elementId": "id170", + "elementId": "id173", + "fixedPoint": [ + "0.50000", + 1, + ], "focus": 0, "gap": 1, }, @@ -1729,7 +1788,7 @@ History { "type": "arrow", "width": 98, "x": 1, - "y": "3.98333", + "y": "34.00000", }, "inserted": { "isDeleted": true, @@ -1737,11 +1796,11 @@ History { }, }, "updated": Map { - "id170" => Delta { + "id173" => Delta { "deleted": { "boundElements": [ { - "id": "id172", + "id": "id175", "type": "arrow", }, ], @@ -1750,11 +1809,11 @@ History { "boundElements": [], }, }, - "id171" => Delta { + "id174" => Delta { "deleted": { "boundElements": [ { - "id": "id172", + "id": "id175", "type": "arrow", }, ], @@ -1787,6 +1846,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1889,7 +1949,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id173", + "id": "id176", "index": "a0", "isDeleted": false, "link": null, @@ -1921,7 +1981,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id174", + "id": "id177", "index": "a1", "isDeleted": false, "link": null, @@ -1963,7 +2023,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id173" => Delta { + "id176" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -1994,7 +2054,7 @@ History { "isDeleted": true, }, }, - "id174" => Delta { + "id177" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -2050,6 +2110,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2113,7 +2174,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id177": true, + "id180": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -2147,7 +2208,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id177", + "id": "id180", "type": "arrow", }, ], @@ -2156,7 +2217,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id175", + "id": "id178", "index": "a0", "isDeleted": false, "link": null, @@ -2184,7 +2245,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": [ { - "id": "id177", + "id": "id180", "type": "arrow", }, ], @@ -2193,7 +2254,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "frameId": null, "groupIds": [], "height": 100, - "id": "id176", + "id": "id179", "index": "a1", "isDeleted": false, "link": null, @@ -2221,17 +2282,19 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { - "elementId": "id176", + "elementId": "id179", + "fixedPoint": null, "focus": 0, "gap": 1, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "373.79942", - "id": "id177", + "height": "408.19672", + "id": "id180", "index": "a2", "isDeleted": false, "lastCommittedPoint": null, @@ -2245,7 +2308,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 498, - "-373.79942", + "-408.19672", ], ], "roughness": 1, @@ -2254,7 +2317,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "startArrowhead": null, "startBinding": { - "elementId": "id175", + "elementId": "id178", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -2263,10 +2327,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": 498, "x": 1, - "y": "-37.91991", + "y": 0, } `; @@ -2290,7 +2354,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id175" => Delta { + "id178" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -2321,7 +2385,7 @@ History { "isDeleted": true, }, }, - "id176" => Delta { + "id179" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -2361,9 +2425,9 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id177": true, + "id180": true, }, - "selectedLinearElementId": "id177", + "selectedLinearElementId": "id180", }, "inserted": { "selectedElementIds": {}, @@ -2374,15 +2438,17 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id177" => Delta { + "id180" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { - "elementId": "id176", + "elementId": "id179", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -2412,7 +2478,8 @@ History { }, "startArrowhead": null, "startBinding": { - "elementId": "id175", + "elementId": "id178", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -2430,11 +2497,11 @@ History { }, }, "updated": Map { - "id175" => Delta { + "id178" => Delta { "deleted": { "boundElements": [ { - "id": "id177", + "id": "id180", "type": "arrow", }, ], @@ -2443,11 +2510,11 @@ History { "boundElements": [], }, }, - "id176" => Delta { + "id179" => Delta { "deleted": { "boundElements": [ { - "id": "id177", + "id": "id180", "type": "arrow", }, ], @@ -2480,6 +2547,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2578,7 +2646,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id144", + "id": "id147", "type": "text", }, ], @@ -2587,7 +2655,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id142", + "id": "id145", "index": "a0", "isDeleted": false, "link": null, @@ -2623,7 +2691,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id143", + "id": "id146", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -2656,7 +2724,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id142", + "containerId": "id145", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -2664,7 +2732,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id144", + "id": "id147", "index": "a2", "isDeleted": false, "lineHeight": "1.25000", @@ -2711,7 +2779,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id142" => Delta { + "id145" => Delta { "deleted": { "isDeleted": false, }, @@ -2742,7 +2810,7 @@ History { "y": 10, }, }, - "id143" => Delta { + "id146" => Delta { "deleted": { "containerId": null, }, @@ -2775,6 +2843,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2873,7 +2942,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id147", + "id": "id150", "type": "text", }, ], @@ -2882,7 +2951,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id145", + "id": "id148", "index": "Zz", "isDeleted": false, "link": null, @@ -2910,7 +2979,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id145", + "containerId": "id148", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -2918,7 +2987,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id146", + "id": "id149", "index": "a0", "isDeleted": true, "lineHeight": "1.25000", @@ -2951,7 +3020,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id145", + "containerId": "id148", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -2959,7 +3028,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id147", + "id": "id150", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -3004,9 +3073,9 @@ History { }, "elementsChange": ElementsChange { "added": Map { - "id146" => Delta { + "id149" => Delta { "deleted": { - "containerId": "id145", + "containerId": "id148", "isDeleted": true, }, "inserted": { @@ -3017,14 +3086,14 @@ History { }, "removed": Map {}, "updated": Map { - "id145" => Delta { + "id148" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id146", + "id": "id149", "type": "text", }, ], @@ -3055,6 +3124,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3153,7 +3223,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id134", + "id": "id137", "type": "text", }, ], @@ -3162,7 +3232,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id132", + "id": "id135", "index": "a0", "isDeleted": false, "link": null, @@ -3190,7 +3260,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id132", + "containerId": "id135", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -3198,7 +3268,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id134", + "id": "id137", "index": "a0V", "isDeleted": false, "lineHeight": "1.25000", @@ -3239,7 +3309,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id133", + "id": "id136", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -3286,11 +3356,11 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id132" => Delta { + "id135" => Delta { "deleted": { "boundElements": [ { - "id": "id134", + "id": "id137", "type": "text", }, ], @@ -3298,23 +3368,23 @@ History { "inserted": { "boundElements": [ { - "id": "id133", + "id": "id136", "type": "text", }, ], }, }, - "id133" => Delta { + "id136" => Delta { "deleted": { "containerId": null, }, "inserted": { - "containerId": "id132", + "containerId": "id135", }, }, - "id134" => Delta { + "id137" => Delta { "deleted": { - "containerId": "id132", + "containerId": "id135", }, "inserted": { "containerId": null, @@ -3345,6 +3415,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3447,7 +3518,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id135", + "id": "id138", "index": "a0", "isDeleted": false, "link": null, @@ -3475,7 +3546,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id136", + "id": "id139", "type": "text", }, ], @@ -3484,7 +3555,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 60, - "id": "id137", + "id": "id140", "index": "a0V", "isDeleted": false, "link": null, @@ -3512,7 +3583,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id137", + "containerId": "id140", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -3520,7 +3591,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 50, - "id": "id136", + "id": "id139", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -3568,32 +3639,32 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id135" => Delta { + "id138" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id136", + "id": "id139", "type": "text", }, ], }, }, - "id136" => Delta { + "id139" => Delta { "deleted": { - "containerId": "id137", + "containerId": "id140", }, "inserted": { - "containerId": "id135", + "containerId": "id138", }, }, - "id137" => Delta { + "id140" => Delta { "deleted": { "boundElements": [ { - "id": "id136", + "id": "id139", "type": "text", }, ], @@ -3627,6 +3698,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3729,7 +3801,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id130", + "id": "id133", "index": "a0", "isDeleted": false, "link": null, @@ -3765,7 +3837,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id131", + "id": "id134", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -3812,25 +3884,25 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id130" => Delta { + "id133" => Delta { "deleted": { "boundElements": [], }, "inserted": { "boundElements": [ { - "id": "id131", + "id": "id134", "type": "text", }, ], }, }, - "id131" => Delta { + "id134" => Delta { "deleted": { "containerId": null, }, "inserted": { - "containerId": "id130", + "containerId": "id133", }, }, }, @@ -3858,6 +3930,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3956,7 +4029,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id139", + "id": "id142", "type": "text", }, ], @@ -3965,7 +4038,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id138", + "id": "id141", "index": "a0", "isDeleted": false, "link": null, @@ -3993,7 +4066,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id138", + "containerId": "id141", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4001,7 +4074,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id139", + "id": "id142", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -4048,7 +4121,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id138" => Delta { + "id141" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -4081,9 +4154,9 @@ History { }, }, "updated": Map { - "id139" => Delta { + "id142" => Delta { "deleted": { - "containerId": "id138", + "containerId": "id141", }, "inserted": { "containerId": null, @@ -4113,6 +4186,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4211,7 +4285,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id141", + "id": "id144", "type": "text", }, ], @@ -4220,7 +4294,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id140", + "id": "id143", "index": "Zz", "isDeleted": false, "link": null, @@ -4248,7 +4322,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id140", + "containerId": "id143", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4256,7 +4330,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id141", + "id": "id144", "index": "a0", "isDeleted": false, "lineHeight": "1.25000", @@ -4303,13 +4377,13 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id141" => Delta { + "id144" => Delta { "deleted": { "angle": 0, "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id140", + "containerId": "id143", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4345,11 +4419,11 @@ History { }, }, "updated": Map { - "id140" => Delta { + "id143" => Delta { "deleted": { "boundElements": [ { - "id": "id141", + "id": "id144", "type": "text", }, ], @@ -4382,6 +4456,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4480,7 +4555,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id155", + "id": "id158", "type": "text", }, ], @@ -4489,7 +4564,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id154", + "id": "id157", "index": "Zz", "isDeleted": false, "link": null, @@ -4517,7 +4592,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id154", + "containerId": "id157", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4525,7 +4600,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id155", + "id": "id158", "index": "a0", "isDeleted": false, "lineHeight": "1.25000", @@ -4572,7 +4647,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id155" => Delta { + "id158" => Delta { "deleted": { "angle": 0, "x": 15, @@ -4609,6 +4684,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4707,7 +4783,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id153", + "id": "id156", "type": "text", }, ], @@ -4716,7 +4792,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id152", + "id": "id155", "index": "a0", "isDeleted": false, "link": null, @@ -4744,7 +4820,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id152", + "containerId": "id155", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4752,7 +4828,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 25, - "id": "id153", + "id": "id156", "index": "a1", "isDeleted": false, "lineHeight": "1.25000", @@ -4800,7 +4876,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id152" => Delta { + "id155" => Delta { "deleted": { "angle": 90, "x": 200, @@ -4836,6 +4912,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4938,7 +5015,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id148", + "id": "id151", "index": "a0", "isDeleted": false, "link": null, @@ -4966,7 +5043,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "autoResize": true, "backgroundColor": "transparent", "boundElements": null, - "containerId": "id148", + "containerId": "id151", "customData": undefined, "fillStyle": "solid", "fontFamily": 5, @@ -4974,7 +5051,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id149", + "id": "id152", "index": "a1", "isDeleted": true, "lineHeight": "1.25000", @@ -5021,7 +5098,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id148" => Delta { + "id151" => Delta { "deleted": { "boundElements": [], "isDeleted": false, @@ -5029,7 +5106,7 @@ History { "inserted": { "boundElements": [ { - "id": "id149", + "id": "id152", "type": "text", }, ], @@ -5061,6 +5138,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5159,7 +5237,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "backgroundColor": "transparent", "boundElements": [ { - "id": "id151", + "id": "id154", "type": "text", }, ], @@ -5168,7 +5246,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id150", + "id": "id153", "index": "Zz", "isDeleted": true, "link": null, @@ -5204,7 +5282,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "frameId": null, "groupIds": [], "height": 100, - "id": "id151", + "id": "id154", "index": "a0", "isDeleted": false, "lineHeight": "1.25000", @@ -5251,13 +5329,13 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id151" => Delta { + "id154" => Delta { "deleted": { "containerId": null, "isDeleted": false, }, "inserted": { - "containerId": "id150", + "containerId": "id153", "isDeleted": true, }, }, @@ -5286,6 +5364,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5388,7 +5467,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "frameId": null, "groupIds": [], "height": 100, - "id": "id179", + "id": "id182", "index": "Zz", "isDeleted": false, "link": null, @@ -5420,7 +5499,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "frameId": null, "groupIds": [], "height": 500, - "id": "id178", + "id": "id181", "index": "a0", "isDeleted": true, "link": null, @@ -5463,7 +5542,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id179" => Delta { + "id182" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -5509,9 +5588,9 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id179" => Delta { + "id182" => Delta { "deleted": { - "frameId": "id178", + "frameId": "id181", }, "inserted": { "frameId": null, @@ -5541,6 +5620,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5600,7 +5680,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id107": true, + "id110": true, }, "resizingElement": null, "scrollX": 0, @@ -5644,7 +5724,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "A", ], "height": 100, - "id": "id106", + "id": "id109", "index": "a0", "isDeleted": false, "link": null, @@ -5678,7 +5758,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "A", ], "height": 100, - "id": "id107", + "id": "id110", "index": "a1", "isDeleted": true, "link": null, @@ -5715,8 +5795,8 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id106": true, - "id107": true, + "id109": true, + "id110": true, }, "selectedGroupIds": { "A": true, @@ -5732,7 +5812,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id106" => Delta { + "id109" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -5765,7 +5845,7 @@ History { "isDeleted": true, }, }, - "id107" => Delta { + "id110" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -5812,7 +5892,7 @@ History { "inserted": { "editingGroupId": null, "selectedElementIds": { - "id106": true, + "id109": true, }, "selectedGroupIds": { "A": true, @@ -5836,7 +5916,7 @@ History { "inserted": { "editingGroupId": "A", "selectedElementIds": { - "id107": true, + "id110": true, }, }, }, @@ -5868,6 +5948,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5927,7 +6008,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id94": true, + "id97": true, }, "resizingElement": null, "scrollX": 0, @@ -5969,7 +6050,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 10, - "id": "id92", + "id": "id95", "index": "a0", "isDeleted": false, "link": null, @@ -6001,7 +6082,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 10, - "id": "id93", + "id": "id96", "index": "a1", "isDeleted": true, "link": null, @@ -6033,7 +6114,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 10, - "id": "id94", + "id": "id97", "index": "a2", "isDeleted": true, "link": null, @@ -6070,7 +6151,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id92": true, + "id95": true, }, }, "inserted": { @@ -6081,7 +6162,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id92" => Delta { + "id95" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6121,12 +6202,12 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id93": true, + "id96": true, }, }, "inserted": { "selectedElementIds": { - "id92": true, + "id95": true, }, }, }, @@ -6135,7 +6216,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id93" => Delta { + "id96" => Delta { "deleted": { "angle": 0, "backgroundColor": "#ffc9c9", @@ -6180,7 +6261,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id93" => Delta { + "id96" => Delta { "deleted": { "backgroundColor": "#ffc9c9", }, @@ -6196,12 +6277,12 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id94": true, + "id97": true, }, }, "inserted": { "selectedElementIds": { - "id93": true, + "id96": true, }, }, }, @@ -6210,7 +6291,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id94" => Delta { + "id97" => Delta { "deleted": { "angle": 0, "backgroundColor": "#ffc9c9", @@ -6255,7 +6336,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id94" => Delta { + "id97" => Delta { "deleted": { "x": 50, "y": 50, @@ -6289,6 +6370,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -6348,14 +6430,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id97": true, + "id100": true, }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id97": true, - "id98": true, + "id100": true, + "id101": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -6393,7 +6475,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id96", + "id": "id99", "index": "a0", "isDeleted": false, "link": null, @@ -6425,7 +6507,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id97", + "id": "id100", "index": "a1", "isDeleted": false, "link": null, @@ -6457,7 +6539,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id98", + "id": "id101", "index": "a2", "isDeleted": false, "link": null, @@ -6494,7 +6576,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id96": true, + "id99": true, }, }, "inserted": { @@ -6505,7 +6587,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id96" => Delta { + "id99" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6536,7 +6618,7 @@ History { "isDeleted": true, }, }, - "id97" => Delta { + "id100" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6567,7 +6649,7 @@ History { "isDeleted": true, }, }, - "id98" => Delta { + "id101" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6607,12 +6689,12 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id97": true, + "id100": true, }, }, "inserted": { "selectedElementIds": { - "id96": true, + "id99": true, }, }, }, @@ -6628,7 +6710,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id98": true, + "id101": true, }, }, "inserted": { @@ -6663,6 +6745,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -6729,10 +6812,10 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id102": true, - "id103": true, - "id104": true, "id105": true, + "id106": true, + "id107": true, + "id108": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": { @@ -6775,7 +6858,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "A", ], "height": 100, - "id": "id102", + "id": "id105", "index": "a0", "isDeleted": false, "link": null, @@ -6809,7 +6892,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "A", ], "height": 100, - "id": "id103", + "id": "id106", "index": "a1", "isDeleted": false, "link": null, @@ -6843,7 +6926,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "B", ], "height": 100, - "id": "id104", + "id": "id107", "index": "a2", "isDeleted": false, "link": null, @@ -6877,7 +6960,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "B", ], "height": 100, - "id": "id105", + "id": "id108", "index": "a3", "isDeleted": false, "link": null, @@ -6914,8 +6997,8 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id102": true, - "id103": true, + "id105": true, + "id106": true, }, "selectedGroupIds": { "A": true, @@ -6938,8 +7021,8 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id104": true, - "id105": true, + "id107": true, + "id108": true, }, "selectedGroupIds": { "B": true, @@ -6978,6 +7061,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7073,13 +7157,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 10, - "id": "id110", + "id": "id113", "index": "a0", "isDeleted": true, "lastCommittedPoint": [ @@ -7132,7 +7217,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id110": true, + "id113": true, }, }, "inserted": { @@ -7144,12 +7229,13 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id110" => Delta { + "id113" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7200,7 +7286,7 @@ History { "appStateChange": AppStateChange { "delta": Delta { "deleted": { - "selectedLinearElementId": "id110", + "selectedLinearElementId": "id113", }, "inserted": { "selectedLinearElementId": null, @@ -7217,7 +7303,7 @@ History { "appStateChange": AppStateChange { "delta": Delta { "deleted": { - "editingLinearElementId": "id110", + "editingLinearElementId": "id113", }, "inserted": { "editingLinearElementId": null, @@ -7238,8 +7324,8 @@ History { "selectedLinearElementId": null, }, "inserted": { - "editingLinearElementId": "id110", - "selectedLinearElementId": "id110", + "editingLinearElementId": "id113", + "selectedLinearElementId": "id113", }, }, }, @@ -7270,6 +7356,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7369,7 +7456,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 10, - "id": "id91", + "id": "id94", "index": "a0", "isDeleted": true, "link": null, @@ -7406,7 +7493,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id91": true, + "id94": true, }, }, "inserted": { @@ -7418,7 +7505,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id91" => Delta { + "id94" => Delta { "deleted": { "angle": 0, "backgroundColor": "#ffec99", @@ -7463,7 +7550,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id91" => Delta { + "id94" => Delta { "deleted": { "backgroundColor": "#ffec99", }, @@ -7495,6 +7582,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7594,7 +7682,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id116", + "id": "id119", "index": "a1", "isDeleted": true, "link": null, @@ -7626,7 +7714,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id117", + "id": "id120", "index": "a3V", "isDeleted": true, "link": null, @@ -7658,7 +7746,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id115", + "id": "id118", "index": "a4", "isDeleted": true, "link": null, @@ -7700,7 +7788,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id116" => Delta { + "id119" => Delta { "deleted": { "index": "a1", }, @@ -7719,14 +7807,14 @@ History { }, "inserted": { "selectedElementIds": { - "id116": true, + "id119": true, }, }, }, }, "elementsChange": ElementsChange { "added": Map { - "id115" => Delta { + "id118" => Delta { "deleted": { "isDeleted": true, }, @@ -7757,7 +7845,7 @@ History { "y": 10, }, }, - "id116" => Delta { + "id119" => Delta { "deleted": { "isDeleted": true, }, @@ -7788,7 +7876,7 @@ History { "y": 20, }, }, - "id117" => Delta { + "id120" => Delta { "deleted": { "isDeleted": true, }, @@ -7846,6 +7934,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7945,7 +8034,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id111", + "id": "id114", "index": "Zx", "isDeleted": true, "link": null, @@ -7977,7 +8066,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id113", + "id": "id116", "index": "Zy", "isDeleted": true, "link": null, @@ -8009,7 +8098,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "frameId": null, "groupIds": [], "height": 100, - "id": "id112", + "id": "id115", "index": "a1", "isDeleted": true, "link": null, @@ -8051,7 +8140,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id112" => Delta { + "id115" => Delta { "deleted": { "index": "a1", }, @@ -8070,14 +8159,14 @@ History { }, "inserted": { "selectedElementIds": { - "id112": true, + "id115": true, }, }, }, }, "elementsChange": ElementsChange { "added": Map { - "id111" => Delta { + "id114" => Delta { "deleted": { "isDeleted": true, }, @@ -8108,7 +8197,7 @@ History { "y": 10, }, }, - "id112" => Delta { + "id115" => Delta { "deleted": { "isDeleted": true, }, @@ -8139,7 +8228,7 @@ History { "y": 20, }, }, - "id113" => Delta { + "id116" => Delta { "deleted": { "isDeleted": true, }, @@ -8197,6 +8286,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8256,15 +8346,15 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": { - "id124": true, - "id125": true, + "id127": true, + "id128": true, }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id124": true, - "id125": true, + "id127": true, + "id128": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -8302,7 +8392,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 10, - "id": "id124", + "id": "id127", "index": "a0", "isDeleted": false, "link": null, @@ -8334,7 +8424,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 10, - "id": "id125", + "id": "id128", "index": "a1", "isDeleted": false, "link": null, @@ -8366,7 +8456,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 100, - "id": "id129", + "id": "id132", "index": "a2", "isDeleted": false, "link": null, @@ -8403,7 +8493,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id124": true, + "id127": true, }, }, "inserted": { @@ -8414,7 +8504,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id124" => Delta { + "id127" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -8454,12 +8544,12 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id125": true, + "id128": true, }, }, "inserted": { "selectedElementIds": { - "id124": true, + "id127": true, }, }, }, @@ -8467,7 +8557,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id125" => Delta { + "id128" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -8507,12 +8597,12 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id124": true, + "id127": true, }, }, "inserted": { "selectedElementIds": { - "id125": true, + "id128": true, }, }, }, @@ -8528,7 +8618,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id125": true, + "id128": true, }, }, "inserted": { @@ -8553,7 +8643,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id124" => Delta { + "id127" => Delta { "deleted": { "x": 90, "y": 90, @@ -8563,7 +8653,7 @@ History { "y": 10, }, }, - "id125" => Delta { + "id128" => Delta { "deleted": { "x": 110, "y": 110, @@ -8597,6 +8687,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8696,7 +8787,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 50, - "id": "id119", + "id": "id122", "index": "a0", "isDeleted": false, "lastCommittedPoint": [ @@ -8755,7 +8846,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 100, - "id": "id120", + "id": "id123", "index": "a1", "isDeleted": false, "link": null, @@ -8797,7 +8888,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id119" => Delta { + "id122" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -8880,6 +8971,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8943,7 +9035,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id121": true, + "id124": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -8981,7 +9073,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 90, - "id": "id121", + "id": "id124", "index": "a0", "isDeleted": false, "link": null, @@ -9013,7 +9105,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "frameId": null, "groupIds": [], "height": 100, - "id": "id123", + "id": "id126", "index": "a1", "isDeleted": false, "link": null, @@ -9050,7 +9142,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id121": true, + "id124": true, }, }, "inserted": { @@ -9061,7 +9153,7 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id121" => Delta { + "id124" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -9107,7 +9199,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id121" => Delta { + "id124" => Delta { "deleted": { "height": 90, "width": 90, @@ -9141,6 +9233,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9401,6 +9494,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9628,6 +9722,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9733,7 +9828,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "B", ], "height": 100, - "id": "id85", + "id": "id88", "index": "a0", "isDeleted": false, "link": null, @@ -9768,7 +9863,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "B", ], "height": 100, - "id": "id86", + "id": "id89", "index": "a1", "isDeleted": false, "link": null, @@ -9802,7 +9897,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "B", ], "height": 100, - "id": "id87", + "id": "id90", "index": "a2", "isDeleted": false, "link": null, @@ -9836,7 +9931,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "B", ], "height": 100, - "id": "id88", + "id": "id91", "index": "a3", "isDeleted": false, "link": null, @@ -9879,7 +9974,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id85" => Delta { + "id88" => Delta { "deleted": { "groupIds": [ "A", @@ -9890,7 +9985,7 @@ History { "groupIds": [], }, }, - "id86" => Delta { + "id89" => Delta { "deleted": { "groupIds": [ "A", @@ -9925,6 +10020,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9988,7 +10084,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "scrollX": 0, "scrollY": 0, "selectedElementIds": { - "id89": true, + "id92": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -10022,13 +10118,14 @@ exports[`history > multiplayer undo/redo > should override remotely added points "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 20, - "id": "id89", + "height": 30, + "id": "id92", "index": "a0", "isDeleted": false, "lastCommittedPoint": [ @@ -10071,8 +10168,8 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 14, - "width": 20, + "version": 13, + "width": 30, "x": 0, "y": 0, } @@ -10093,7 +10190,7 @@ History { "delta": Delta { "deleted": { "selectedElementIds": { - "id89": true, + "id92": true, }, }, "inserted": { @@ -10104,12 +10201,13 @@ History { "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id89" => Delta { + "id92" => Delta { "deleted": { "angle": 0, "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -10168,7 +10266,7 @@ History { "added": Map {}, "removed": Map {}, "updated": Map { - "id89" => Delta { + "id92" => Delta { "deleted": { "height": 30, "lastCommittedPoint": [ @@ -10225,28 +10323,260 @@ History { "appStateChange": AppStateChange { "delta": Delta { "deleted": { - "selectedLinearElementId": "id89", + "selectedLinearElementId": "id92", + }, + "inserted": { + "selectedLinearElementId": null, + }, + }, + }, + "elementsChange": ElementsChange { + "added": Map {}, + "removed": Map {}, + "updated": Map {}, + }, + }, + ], +} +`; + +exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; + +exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `17`; + +exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = ` +{ + "activeEmbeddable": null, + "activeTool": { + "customType": null, + "lastActiveTool": null, + "locked": false, + "type": "selection", + }, + "collaborators": Map {}, + "contextMenu": null, + "currentChartType": "bar", + "currentHoveredFontFamily": null, + "currentItemArrowType": "round", + "currentItemBackgroundColor": "transparent", + "currentItemEndArrowhead": "arrow", + "currentItemFillStyle": "solid", + "currentItemFontFamily": 5, + "currentItemFontSize": 20, + "currentItemOpacity": 100, + "currentItemRoughness": 1, + "currentItemRoundness": "round", + "currentItemStartArrowhead": null, + "currentItemStrokeColor": "#1e1e1e", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 2, + "currentItemTextAlign": "left", + "cursorButton": "up", + "defaultSidebarDockedPreference": false, + "draggingElement": null, + "editingElement": null, + "editingFrame": null, + "editingGroupId": null, + "editingLinearElement": null, + "elementsToHighlight": null, + "errorMessage": null, + "exportBackground": true, + "exportEmbedScene": false, + "exportScale": 1, + "exportWithDarkMode": false, + "fileHandle": null, + "followedBy": Set {}, + "frameRendering": { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, + "frameToHighlight": null, + "gridSize": null, + "height": 0, + "isBindingEnabled": true, + "isLoading": false, + "isResizing": false, + "isRotating": false, + "lastPointerDownWith": "mouse", + "multiElement": null, + "objectsSnapModeEnabled": false, + "offsetLeft": 0, + "offsetTop": 0, + "openDialog": null, + "openMenu": null, + "openPopup": null, + "openSidebar": null, + "originSnapOffset": null, + "pasteDialog": { + "data": null, + "shown": false, + }, + "penDetected": false, + "penMode": false, + "pendingImageElementId": null, + "previousSelectedElementIds": {}, + "resizingElement": null, + "scrollX": 0, + "scrollY": 0, + "selectedElementIds": {}, + "selectedElementsAreBeingDragged": false, + "selectedGroupIds": {}, + "selectionElement": null, + "shouldCacheIgnoreZoom": false, + "showHyperlinkPopup": false, + "showWelcomeScreen": true, + "snapLines": [], + "startBoundElement": null, + "stats": { + "open": false, + "panels": 3, + }, + "suggestedBindings": [], + "theme": "light", + "toast": null, + "userToFollow": null, + "viewBackgroundColor": "#ffffff", + "viewModeEnabled": false, + "width": 0, + "zenModeEnabled": false, + "zoom": { + "value": 1, + }, +} +`; + +exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] element 0 1`] = ` +{ + "angle": 0, + "backgroundColor": "#ffec99", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "id": "id93", + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": { + "type": 3, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 7, + "width": 10, + "x": 10, + "y": 0, +} +`; + +exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] history 1`] = ` +History { + "onHistoryChangedEmitter": Emitter { + "subscribers": [ + [Function], + [Function], + ], + }, + "redoStack": [], + "undoStack": [ + HistoryEntry { + "appStateChange": AppStateChange { + "delta": Delta { + "deleted": { + "selectedElementIds": { + "id93": true, + }, + }, + "inserted": { + "selectedElementIds": {}, + }, + }, + }, + "elementsChange": ElementsChange { + "added": Map {}, + "removed": Map { + "id93" => Delta { + "deleted": { + "angle": 0, + "backgroundColor": "#ffec99", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 10, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": { + "type": 3, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "width": 10, + "x": 10, + "y": 0, + }, + "inserted": { + "isDeleted": true, + }, + }, + }, + "updated": Map {}, + }, + }, + HistoryEntry { + "appStateChange": AppStateChange { + "delta": Delta { + "deleted": { + "selectedElementIds": {}, }, "inserted": { - "selectedLinearElementId": null, + "selectedElementIds": { + "id93": true, + }, }, }, }, "elementsChange": ElementsChange { "added": Map {}, "removed": Map {}, - "updated": Map {}, + "updated": Map { + "id93" => Delta { + "deleted": { + "isDeleted": false, + }, + "inserted": { + "isDeleted": false, + }, + }, + }, }, }, ], } `; -exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; +exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `17`; +exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] number of renders 1`] = `12`; -exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = ` +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] appState 1`] = ` { "activeEmbeddable": null, "activeTool": { @@ -10259,6 +10589,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10309,7 +10640,10 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "openMenu": null, "openPopup": null, "openSidebar": null, - "originSnapOffset": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -10348,17 +10682,22 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme } `; -exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] element 0 1`] = ` +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] element 0 1`] = ` { "angle": 0, - "backgroundColor": "#ffec99", - "boundElements": null, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "6Rm4g567UQM4WjLwej2Vc", + "type": "arrow", + }, + ], "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 10, - "id": "id90", + "height": 126, + "id": "KPrBI4g_v9qUB1XxYLgSz", "index": "a0", "isDeleted": false, "link": null, @@ -10373,14 +10712,119 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 7, - "width": 10, - "x": 10, + "version": 6, + "width": 157, + "x": 600, "y": 0, } `; -exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] history 1`] = ` +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "6Rm4g567UQM4WjLwej2Vc", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 129, + "id": "u2JGnnmoJ0VATV4vCNJE5", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": { + "type": 3, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "updated": 1, + "version": 6, + "width": 124, + "x": 1152, + "y": 516, +} +`; + +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] element 2 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": true, + "endArrowhead": null, + "endBinding": { + "elementId": "u2JGnnmoJ0VATV4vCNJE5", + "fixedPoint": [ + "0.49919", + "-0.03875", + ], + "focus": "-0.00161", + "gap": "3.53708", + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": "448.10100", + "id": "6Rm4g567UQM4WjLwej2Vc", + "index": "a2", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + "451.90000", + 0, + ], + [ + "451.90000", + "448.10100", + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": { + "elementId": "KPrBI4g_v9qUB1XxYLgSz", + "fixedPoint": [ + "1.03185", + "0.49921", + ], + "focus": "-0.00159", + "gap": 5, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 6, + "width": "451.90000", + "x": 762, + "y": "62.90000", +} +`; + +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] history 1`] = ` History { "onHistoryChangedEmitter": Emitter { "subscribers": [ @@ -10393,29 +10837,23 @@ History { HistoryEntry { "appStateChange": AppStateChange { "delta": Delta { - "deleted": { - "selectedElementIds": { - "id90": true, - }, - }, - "inserted": { - "selectedElementIds": {}, - }, + "deleted": {}, + "inserted": {}, }, }, "elementsChange": ElementsChange { "added": Map {}, "removed": Map { - "id90" => Delta { + "KPrBI4g_v9qUB1XxYLgSz" => Delta { "deleted": { "angle": 0, - "backgroundColor": "#ffec99", + "backgroundColor": "transparent", "boundElements": null, "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 10, + "height": 126, "index": "a0", "isDeleted": false, "link": null, @@ -10429,9 +10867,40 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", - "width": 10, - "x": 10, - "y": 0, + "width": 157, + "x": 873, + "y": 212, + }, + "inserted": { + "isDeleted": true, + }, + }, + "u2JGnnmoJ0VATV4vCNJE5" => Delta { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 129, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": { + "type": 3, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "diamond", + "width": 124, + "x": 1152, + "y": 516, }, "inserted": { "isDeleted": true, @@ -10444,26 +10913,106 @@ History { HistoryEntry { "appStateChange": AppStateChange { "delta": Delta { - "deleted": { - "selectedElementIds": {}, - }, - "inserted": { - "selectedElementIds": { - "id90": true, - }, - }, + "deleted": {}, + "inserted": {}, }, }, "elementsChange": ElementsChange { "added": Map {}, - "removed": Map {}, - "updated": Map { - "id90" => Delta { + "removed": Map { + "6Rm4g567UQM4WjLwej2Vc" => Delta { "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "customData": undefined, + "elbowed": true, + "endArrowhead": null, + "endBinding": { + "elementId": "u2JGnnmoJ0VATV4vCNJE5", + "fixedPoint": [ + "0.49919", + "-0.03875", + ], + "focus": "-0.00161", + "gap": "3.53708", + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": "236.10000", + "index": "a2", "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + [ + "178.90000", + 0, + ], + [ + "178.90000", + "236.10000", + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "startArrowhead": null, + "startBinding": { + "elementId": "KPrBI4g_v9qUB1XxYLgSz", + "fixedPoint": [ + "1.03185", + "0.49921", + ], + "focus": "-0.00159", + "gap": 5, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "width": "178.90000", + "x": 1035, + "y": "274.90000", }, "inserted": { - "isDeleted": false, + "isDeleted": true, + }, + }, + }, + "updated": Map { + "KPrBI4g_v9qUB1XxYLgSz" => Delta { + "deleted": { + "boundElements": [ + { + "id": "6Rm4g567UQM4WjLwej2Vc", + "type": "arrow", + }, + ], + }, + "inserted": { + "boundElements": [], + }, + }, + "u2JGnnmoJ0VATV4vCNJE5" => Delta { + "deleted": { + "boundElements": [ + { + "id": "6Rm4g567UQM4WjLwej2Vc", + "type": "arrow", + }, + ], + }, + "inserted": { + "boundElements": [], }, }, }, @@ -10473,9 +11022,9 @@ History { } `; -exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] number of elements 1`] = `1`; +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] number of renders 1`] = `12`; +exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end of test] number of renders 1`] = `8`; exports[`history > multiplayer undo/redo > should update history entries after remote changes on the same properties > [end of test] appState 1`] = ` { @@ -10490,6 +11039,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10740,6 +11290,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10975,6 +11526,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11212,6 +11764,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11609,6 +12162,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11852,6 +12406,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12089,6 +12644,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12326,6 +12882,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12569,6 +13126,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12897,6 +13455,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13065,6 +13624,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13349,6 +13909,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13612,6 +14173,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13883,6 +14445,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -14040,6 +14603,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -14258,9 +14822,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id52", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14292,6 +14858,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id50", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14300,7 +14867,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": 98, "x": 1, "y": 0, @@ -14626,9 +15193,11 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id52", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14659,6 +15228,7 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id50", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14726,6 +15296,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -14944,9 +15515,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id46", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14978,6 +15551,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id44", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -14986,7 +15560,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": 98, "x": 1, "y": 0, @@ -15236,9 +15810,11 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id46", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15269,6 +15845,7 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id44", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15336,6 +15913,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -15554,9 +16132,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id58", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15588,6 +16168,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id56", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15596,7 +16177,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": 98, "x": 1, "y": 0, @@ -15846,9 +16427,11 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id58", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15879,6 +16462,7 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id56", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15946,6 +16530,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -16162,9 +16747,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id64", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16196,6 +16783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id62", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16204,7 +16792,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": 98, "x": 1, "y": 0, @@ -16268,6 +16856,7 @@ History { ], "startBinding": { "elementId": "id62", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16524,9 +17113,11 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id64", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16557,6 +17148,7 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id62", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16647,6 +17239,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -16866,9 +17459,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id71", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16900,6 +17495,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id69", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16908,7 +17504,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 11, "width": 98, "x": 1, "y": 0, @@ -16971,6 +17567,7 @@ History { "deleted": { "endBinding": { "elementId": "id71", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16986,6 +17583,7 @@ History { ], "startBinding": { "elementId": "id69", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17243,9 +17841,11 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id71", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17276,6 +17876,7 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id69", + "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17385,6 +17986,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -17855,6 +18457,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -18373,6 +18976,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -18825,6 +19429,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -18924,6 +19529,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -19004,6 +19610,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 958a8c88cc..c6ea4b6473 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = ` "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 1984422985, + "versionNonce": 745419401, "width": 300, "x": 201, "y": 2, @@ -186,16 +186,18 @@ exports[`move element > rectangles with binding arrow 7`] = ` "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": { "elementId": "id1", + "fixedPoint": null, "focus": "-0.46667", "gap": 10, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "81.48231", + "height": "81.47368", "id": "id2", "index": "a2", "isDeleted": false, @@ -210,7 +212,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` ], [ 81, - "81.48231", + "81.47368", ], ], "roughness": 1, @@ -221,6 +223,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startArrowhead": null, "startBinding": { "elementId": "id0", + "fixedPoint": null, "focus": "-0.60000", "gap": 10, }, @@ -229,10 +232,10 @@ exports[`move element > rectangles with binding arrow 7`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 14, - "versionNonce": 2066753033, + "version": 11, + "versionNonce": 1996028265, "width": 81, "x": 110, - "y": "49.98179", + "y": 50, } `; diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index d7602f15f0..05d6b9cddc 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -6,6 +6,7 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index f2d6e2c47e..e1321771c8 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -421,6 +422,7 @@ exports[`given element A and group of elements B and given both are selected whe "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -820,6 +822,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1358,6 +1361,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1555,6 +1559,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -1923,6 +1928,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2156,6 +2162,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2329,6 +2336,7 @@ exports[`regression tests > can drag element that covers another element, while "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2642,6 +2650,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -2881,6 +2890,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3117,6 +3127,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3340,6 +3351,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3589,6 +3601,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -3893,6 +3906,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4300,6 +4314,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4606,6 +4621,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -4882,6 +4898,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5115,6 +5132,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5307,6 +5325,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5682,6 +5701,7 @@ exports[`regression tests > drags selected elements from point inside common bou "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -5965,6 +5985,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -6247,6 +6268,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -6387,6 +6409,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -6764,6 +6787,7 @@ exports[`regression tests > given a group of selected elements with an element t "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7087,6 +7111,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7356,6 +7381,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7583,6 +7609,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7813,6 +7840,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -7986,6 +8014,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8159,6 +8188,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8332,6 +8362,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8408,6 +8439,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "isDragging": false, "lastUncommittedPoint": null, "pointerDownState": { + "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -8480,6 +8512,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8545,6 +8578,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8621,6 +8655,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "isDragging": false, "lastUncommittedPoint": null, "pointerDownState": { + "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -8758,6 +8793,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -8945,6 +8981,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9021,6 +9058,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "isDragging": false, "lastUncommittedPoint": null, "pointerDownState": { + "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9093,6 +9131,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -9158,6 +9197,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9331,6 +9371,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9407,6 +9448,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "isDragging": false, "lastUncommittedPoint": null, "pointerDownState": { + "lastClickedIsEndPoint": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9544,6 +9586,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9717,6 +9760,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -9904,6 +9948,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10077,6 +10122,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10584,6 +10630,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10854,6 +10901,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -10973,6 +11021,7 @@ exports[`regression tests > shift click on selected element should deselect it o "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11165,6 +11214,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11469,6 +11519,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -11874,6 +11925,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12480,6 +12532,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -12602,6 +12655,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13179,6 +13233,7 @@ exports[`regression tests > switches from group of selected elements to another "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13540,6 +13595,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13828,6 +13884,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -13947,6 +14004,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -14146,6 +14204,7 @@ History { "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -14318,6 +14377,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", @@ -14437,6 +14497,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap index 42a3aa84e5..2eea908425 100644 --- a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap @@ -6,6 +6,7 @@ exports[`select single element on the scene > arrow 1`] = ` "backgroundColor": "transparent", "boundElements": null, "customData": undefined, + "elbowed": false, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index 9bc9947c7f..3209043b0b 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -62,6 +62,7 @@ describe("element binding", () => { expect(arrow.startBinding).toEqual({ elementId: rect.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -74,11 +75,13 @@ describe("element binding", () => { // Both the start and the end points should be bound expect(arrow.startBinding).toEqual({ elementId: rect.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -318,11 +321,13 @@ describe("element binding", () => { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, endBinding: { elementId: "text1", focus: 0.2, gap: 7, + fixedPoint: [1, 0.5], }, }); @@ -337,11 +342,13 @@ describe("element binding", () => { elementId: "text1", focus: 0.2, gap: 7, + fixedPoint: [0.5, 1], }, endBinding: { elementId: "rectangle1", focus: 0.2, gap: 7, + fixedPoint: [1, 0.5], }, }); diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index 5c72dd53d0..d0c81fb907 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -6,6 +6,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = ` "backgroundColor": "transparent", "boundElements": [], "customData": undefined, + "elbowed": false, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 621c31bdf8..164615f771 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -149,8 +149,6 @@ const createLinearElementWithCurveInsideMinMaxPoints = ( [-922.4761962890625, 300.3277587890625], [828.0126953125, 410.51605224609375], ], - startArrowhead: null, - endArrowhead: null, }); }; @@ -183,8 +181,6 @@ const createLinearElementsWithCurveOutsideMinMaxPoints = ( [-591.2804897585779, 36.09360810181511], [-148.56510566829502, 53.96308359105342], ], - startArrowhead: null, - endArrowhead: null, ...extraProps, }); }; diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index f44d780407..3b83df8464 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -19,6 +19,7 @@ import util from "util"; import path from "path"; import { getMimeType } from "../../data/blob"; import { + newArrowElement, newEmbeddableElement, newFrameElement, newFreeDrawElement, @@ -146,6 +147,7 @@ export class API { endBinding?: T extends "arrow" ? ExcalidrawLinearElement["endBinding"] : never; + elbowed?: boolean; }): T extends "arrow" | "line" ? ExcalidrawLinearElement : T extends "freedraw" @@ -250,14 +252,24 @@ export class API { }); break; case "arrow": + element = newArrowElement({ + ...base, + width, + height, + type, + points: rest.points ?? [ + [0, 0], + [100, 100], + ], + elbowed: rest.elbowed ?? false, + }); + break; case "line": element = newLinearElement({ ...base, width, height, type, - startArrowhead: null, - endArrowhead: null, points: rest.points ?? [ [0, 0], [100, 100], diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 4c48d5f6d3..1abfb4d102 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1,3 +1,5 @@ +import "../global.d.ts"; +import React from "react"; import * as StaticScene from "../renderer/staticScene"; import { GlobalTestState, @@ -24,6 +26,7 @@ import { import { KEYS } from "../keys"; import { newElementWith } from "../element/mutateElement"; import type { + ExcalidrawElbowArrowElement, ExcalidrawFrameElement, ExcalidrawGenericElement, ExcalidrawLinearElement, @@ -41,6 +44,7 @@ import { queryByText } from "@testing-library/react"; import { HistoryEntry } from "../history"; import { AppStateChange, ElementsChange } from "../change"; import { Snapshot, StoreAction } from "../store"; +import type Scene from "../scene/Scene"; const { h } = window; @@ -114,6 +118,7 @@ describe("history", () => { arrayToMap(h.elements) as SceneElementsMap, appState, Snapshot.empty(), + {} as Scene, ) as any, ); } catch (e) { @@ -135,6 +140,7 @@ describe("history", () => { arrayToMap(h.elements) as SceneElementsMap, appState, Snapshot.empty(), + {} as Scene, ) as any, ); } catch (e) { @@ -1332,11 +1338,13 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1355,11 +1363,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1378,11 +1388,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1409,11 +1421,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1432,11 +1446,13 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, + fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1466,37 +1482,41 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(5); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [], - isDeleted: true, - }), - expect.objectContaining({ - id: text.id, - containerId: null, - isDeleted: true, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [], - isDeleted: true, - }), - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: true, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [], + isDeleted: true, + }), + expect.objectContaining({ + id: text.id, + containerId: null, + isDeleted: true, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [], + isDeleted: true, + }), + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: true, + }), + ]), + ); Keyboard.redo(); Keyboard.redo(); @@ -1506,40 +1526,44 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [ - { id: text.id, type: "text" }, - { id: arrow.id, type: "arrow" }, - ], - isDeleted: false, - }), - expect.objectContaining({ - id: text.id, - containerId: rect1.id, - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: expect.arrayContaining([ + { id: text.id, type: "text" }, + { id: arrow.id, type: "arrow" }, + ]), + isDeleted: false, + }), + expect.objectContaining({ + id: text.id, + containerId: rect1.id, + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + ]), + ); }); it("should unbind rectangle from arrow on deletion and rebind on undo", async () => { @@ -1547,74 +1571,80 @@ describe("history", () => { Keyboard.keyPress(KEYS.DELETE); expect(API.getUndoStack().length).toBe(7); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [ - { id: text.id, type: "text" }, - { id: arrow.id, type: "arrow" }, - ], - isDeleted: true, - }), - expect.objectContaining({ - id: text.id, - containerId: rect1.id, - isDeleted: true, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: arrow.id, - startBinding: null, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [ + { id: text.id, type: "text" }, + { id: arrow.id, type: "arrow" }, + ], + isDeleted: true, + }), + expect.objectContaining({ + id: text.id, + containerId: rect1.id, + isDeleted: true, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: arrow.id, + startBinding: null, + endBinding: expect.objectContaining({ + elementId: rect2.id, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + ]), + ); Keyboard.undo(); expect(API.getUndoStack().length).toBe(6); expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [ - { id: arrow.id, type: "arrow" }, - { id: text.id, type: "text" }, // order has now changed! - ], - isDeleted: false, - }), - expect.objectContaining({ - id: text.id, - containerId: rect1.id, - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: expect.arrayContaining([ + { id: arrow.id, type: "arrow" }, + { id: text.id, type: "text" }, // order has now changed! + ]), + isDeleted: false, + }), + expect.objectContaining({ + id: text.id, + containerId: rect1.id, + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + ]), + ); }); it("should unbind rectangles from arrow on deletion and rebind on undo", async () => { @@ -1652,40 +1682,44 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(7); expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [ - { id: arrow.id, type: "arrow" }, - { id: text.id, type: "text" }, // order has now changed! - ], - isDeleted: false, - }), - expect.objectContaining({ - id: text.id, - containerId: rect1.id, - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: expect.arrayContaining([ + { id: arrow.id, type: "arrow" }, + { id: text.id, type: "text" }, // order has now changed! + ]), + isDeleted: false, + }), + expect.objectContaining({ + id: text.id, + containerId: rect1.id, + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + ]), + ); }); }); @@ -1977,8 +2011,112 @@ describe("history", () => { ]); }); - // TODO: #7348 ideally we should not override, but since the order of groupIds matters, right now we cannot ensure that with postprocssed groupIds the order will be consistent after series or undos/redos, we don't postprocess them at all - // in other words, if we would postprocess groupIds, the groupIds order on "redo" below would be ["B", "A"] instead of ["A", "B"] + it("should redraw arrows on undo", () => { + const rect = API.createElement({ + type: "rectangle", + id: "KPrBI4g_v9qUB1XxYLgSz", + x: 873, + y: 212, + width: 157, + height: 126, + }); + const diamond = API.createElement({ + id: "u2JGnnmoJ0VATV4vCNJE5", + type: "diamond", + x: 1152, + y: 516, + width: 124, + height: 129, + }); + const arrow = API.createElement({ + type: "arrow", + id: "6Rm4g567UQM4WjLwej2Vc", + elbowed: true, + }); + + excalidrawAPI.updateScene({ + elements: [rect, diamond], + storeAction: StoreAction.CAPTURE, + }); + + // Connect the arrow + excalidrawAPI.updateScene({ + elements: [ + { + ...rect, + boundElements: [ + { + id: "6Rm4g567UQM4WjLwej2Vc", + type: "arrow", + }, + ], + }, + { + ...diamond, + boundElements: [ + { + id: "6Rm4g567UQM4WjLwej2Vc", + type: "arrow", + }, + ], + }, + { + ...arrow, + x: 1035, + y: 274.9, + width: 178.9000000000001, + height: 236.10000000000002, + points: [ + [0, 0], + [178.9000000000001, 0], + [178.9000000000001, 236.10000000000002], + ], + startBinding: { + elementId: "KPrBI4g_v9qUB1XxYLgSz", + focus: -0.001587301587301948, + gap: 5, + fixedPoint: [1.0318471337579618, 0.49920634920634904], + }, + endBinding: { + elementId: "u2JGnnmoJ0VATV4vCNJE5", + focus: -0.0016129032258049847, + gap: 3.537079145500037, + fixedPoint: [0.4991935483870975, -0.03875193720914723], + }, + }, + ], + storeAction: StoreAction.CAPTURE, + }); + + Keyboard.undo(); + + excalidrawAPI.updateScene({ + elements: h.elements.map((el) => + el.id === "KPrBI4g_v9qUB1XxYLgSz" + ? { + ...el, + x: 600, + y: 0, + } + : el, + ), + storeAction: StoreAction.UPDATE, + }); + + Keyboard.redo(); + + const modifiedArrow = h.elements.filter( + (el) => el.type === "arrow", + )[0] as ExcalidrawElbowArrowElement; + expect(modifiedArrow.points).toEqual([ + [0, 0], + [451.9000000000001, 0], + [451.9000000000001, 448.10100010002003], + ]); + }); + + // TODO: #7348 ideally we should not override, but since the order of groupIds matters, right now we cannot ensure that with postprocssed groupIds the order will be consistent after series or undos/redos, we don't postprocess them at all + // in other words, if we would postprocess groupIds, the groupIds order on "redo" below would be ["B", "A"] instead of ["A", "B"] it("should override remotely added groups on undo, but restore them on redo", async () => { const rect1 = API.createElement({ type: "rectangle" }); const rect2 = API.createElement({ type: "rectangle" }); @@ -4149,29 +4287,33 @@ describe("history", () => { mouse.moveTo(100, 0); mouse.up(); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: arrowId, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: arrowId, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + ]), + ); Keyboard.undo(); // undo start binding Keyboard.undo(); // undo end binding @@ -4214,29 +4356,35 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: arrowId, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: expect.arrayContaining([ + { id: arrowId, type: "arrow" }, + ]), + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: arrowId, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + ]), + ); Keyboard.undo(); Keyboard.undo(); @@ -4277,29 +4425,33 @@ describe("history", () => { mouse.moveTo(100, 1); mouse.upAt(100, 0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: arrowId, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: arrowId, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + ]), + ); Keyboard.undo(); Keyboard.undo(); @@ -4331,7 +4483,12 @@ describe("history", () => { h.elements[0], newElementWith(h.elements[1], { boundElements: [] }), newElementWith(h.elements[2] as ExcalidrawLinearElement, { - endBinding: { elementId: remoteContainer.id, gap: 1, focus: 0 }, + endBinding: { + elementId: remoteContainer.id, + gap: 1, + focus: 0, + fixedPoint: [0.5, 1], + }, }), remoteContainer, ], @@ -4343,72 +4500,92 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: arrowId, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - // rebound with previous rectangle - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - expect.objectContaining({ - id: remoteContainer.id, - boundElements: [], - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: arrowId, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + // rebound with previous rectangle + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + expect.objectContaining({ + id: remoteContainer.id, + boundElements: [], + }), + ]), + ); Keyboard.undo(); Keyboard.undo(); expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [], - }), - expect.objectContaining({ - id: arrowId, - startBinding: null, - endBinding: { - // now we are back in the previous state! - elementId: remoteContainer.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - expect.objectContaining({ - id: remoteContainer.id, - // leaving as bound until we can rebind arrows! - boundElements: [{ id: arrowId, type: "arrow" }], - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [], + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [], + }), + expect.objectContaining({ + id: arrowId, + startBinding: null, + endBinding: expect.objectContaining({ + // now we are back in the previous state! + elementId: remoteContainer.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + expect.objectContaining({ + id: remoteContainer.id, + // leaving as bound until we can rebind arrows! + boundElements: [{ id: arrowId, type: "arrow" }], + }), + ]), + ); }); }); it("should rebind remotely added arrow when it's bindable elements are added through the history", async () => { const arrow = API.createElement({ type: "arrow", - startBinding: { elementId: rect1.id, gap: 1, focus: 0 }, - endBinding: { elementId: rect2.id, gap: 1, focus: 0 }, + startBinding: { + elementId: rect1.id, + gap: 1, + focus: 0, + fixedPoint: [1, 0.5], + }, + endBinding: { + elementId: rect2.id, + gap: 1, + focus: 0, + fixedPoint: [0.5, 1], + }, }); // Simulate remote update @@ -4450,33 +4627,43 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: arrow.id, - startBinding: { - // now we are back in the previous state! - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - // now we are back in the previous state! - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - }), - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + // now we are back in the previous state! + elementId: rect1.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + // now we are back in the previous state! + elementId: rect2.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + }), + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + ]), + ); }); }); @@ -4496,8 +4683,18 @@ describe("history", () => { excalidrawAPI.updateScene({ elements: [ newElementWith(h.elements[0] as ExcalidrawLinearElement, { - startBinding: { elementId: rect1.id, gap: 1, focus: 0 }, - endBinding: { elementId: rect2.id, gap: 1, focus: 0 }, + startBinding: { + elementId: rect1.id, + gap: 1, + focus: 0, + fixedPoint: [0.5, 1], + }, + endBinding: { + elementId: rect2.id, + gap: 1, + focus: 0, + fixedPoint: [1, 0.5], + }, }), newElementWith(rect1, { boundElements: [{ id: arrow.id, type: "arrow" }], @@ -4513,62 +4710,82 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: true, - }), - expect.objectContaining({ - id: rect1.id, - boundElements: [], - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [], - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: arrow.id, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: true, + }), + expect.objectContaining({ + id: rect1.id, + boundElements: [], + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [], + isDeleted: false, + }), + ]), + ); Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: arrow.id, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrow.id, type: "arrow" }], - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: arrow.id, + startBinding: { + elementId: rect1.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }, + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: [ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ], + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrow.id, type: "arrow" }], + isDeleted: false, + }), + ]), + ); }); }); @@ -4585,31 +4802,35 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [], - }), - expect.objectContaining({ id: rect2.id, boundElements: [] }), - expect.objectContaining({ - id: arrowId, - points: [ - [0, 0], - [100, 0], - ], - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: true, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [], + }), + expect.objectContaining({ id: rect2.id, boundElements: [] }), + expect.objectContaining({ + id: arrowId, + points: [ + [0, 0], + [100, 0], + ], + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: true, + }), + ]), + ); // Simulate remote update excalidrawAPI.updateScene({ @@ -4632,30 +4853,34 @@ describe("history", () => { roundToNearestHundred(points[1]), ]).toEqual([500, -400]); } - expect(h.elements).toEqual([ - expect.objectContaining({ - id: rect1.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: rect2.id, - boundElements: [{ id: arrowId, type: "arrow" }], - }), - expect.objectContaining({ - id: arrowId, - startBinding: { - elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - endBinding: { - elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), - }, - isDeleted: false, - }), - ]); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rect1.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: rect2.id, + boundElements: [{ id: arrowId, type: "arrow" }], + }), + expect.objectContaining({ + id: arrowId, + startBinding: expect.objectContaining({ + elementId: rect1.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + endBinding: expect.objectContaining({ + elementId: rect2.id, + fixedPoint: null, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }), + isDeleted: false, + }), + ]), + ); }); }); diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 9d8fd9dc9e..449eca1ba9 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -95,7 +95,12 @@ describe("library", () => { const arrow = API.createElement({ id: "arrow1", type: "arrow", - endBinding: { elementId: "rectangle1", focus: -1, gap: 0 }, + endBinding: { + elementId: "rectangle1", + focus: -1, + gap: 0, + fixedPoint: [0.5, 1], + }, }); await API.drop( diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 00273f51de..02e58d209b 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -5,7 +5,7 @@ import type { ExcalidrawTextElementWithContainer, FontString, } from "../element/types"; -import { Excalidraw } from "../index"; +import { Excalidraw, mutateElement } from "../index"; import { centerPoint } from "../math"; import { reseed } from "../random"; import * as StaticScene from "../renderer/staticScene"; @@ -107,6 +107,7 @@ describe("Test Linear Elements", () => { ], roundness, }); + mutateElement(line, { points: line.points }); h.elements = [line]; mouse.clickAt(p1[0], p1[1]); return line; @@ -307,7 +308,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `9`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, @@ -365,7 +366,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, @@ -427,7 +428,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(5); @@ -478,7 +479,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -519,7 +520,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -567,7 +568,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `18`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -617,7 +618,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -715,7 +716,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -843,6 +844,7 @@ describe("Test Linear Elements", () => { id: textElement.id, }), }; + const elements: ExcalidrawElement[] = []; h.elements.forEach((element) => { if (element.id === container.id) { @@ -1235,7 +1237,7 @@ describe("Test Linear Elements", () => { mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBe(200); + expect(arrow.width).toBe(205); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( @@ -1356,16 +1358,20 @@ describe("Test Linear Elements", () => { const line = createThreePointerLinearElement("arrow"); const [origStartX, origStartY] = [line.x, line.y]; - LinearElementEditor.movePoints(line, [ - { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] }, - { - index: line.points.length - 1, - point: [ - line.points[line.points.length - 1][0] - 10, - line.points[line.points.length - 1][1] - 10, - ], - }, - ]); + LinearElementEditor.movePoints( + line, + [ + { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] }, + { + index: line.points.length - 1, + point: [ + line.points[line.points.length - 1][0] - 10, + line.points[line.points.length - 1][1] - 10, + ], + }, + ], + h.scene, + ); expect(line.x).toBe(origStartX + 10); expect(line.y).toBe(origStartY + 10); diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 6e0ef90274..4951a7090b 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -13,6 +13,7 @@ import type { import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; import { vi } from "vitest"; +import type Scene from "../scene/Scene"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -85,6 +86,7 @@ describe("move element", () => { rectA.get() as ExcalidrawRectangleElement, rectB.get() as ExcalidrawRectangleElement, elementsMap, + {} as Scene, ); // select the second rectangle diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 95659b094c..67e19621d4 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -798,6 +798,7 @@ describe("multiple selection", () => { width: 100, height: 0, }); + const rightBoundArrow = UI.createElement("arrow", { x: 210, y: 50, @@ -822,11 +823,16 @@ describe("multiple selection", () => { expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(140, 0); + expect(leftBoundArrow.width).toBeCloseTo(137.5, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.startBinding).toBeNull(); - expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding); + expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352); + expect(leftBoundArrow.endBinding?.elementId).toBe( + leftArrowBinding.elementId, + ); + expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull(); + expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.y).toBeCloseTo( @@ -836,7 +842,12 @@ describe("multiple selection", () => { expect(rightBoundArrow.height).toBeCloseTo(0); expect(rightBoundArrow.angle).toEqual(0); expect(rightBoundArrow.startBinding).toBeNull(); - expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding); + expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); + expect(rightBoundArrow.endBinding?.elementId).toBe( + rightArrowBinding.elementId, + ); + expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull(); + expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus); }); it("resizes with labeled arrows", async () => { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e45d5a6925..e39bb4c96c 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -281,6 +281,7 @@ export interface AppState { currentItemEndArrowhead: Arrowhead | null; currentHoveredFontFamily: FontFamilyValues | null; currentItemRoundness: StrokeRoundness; + currentItemArrowType: "sharp" | "round" | "elbow"; viewBackgroundColor: string; scrollX: number; scrollY: number; @@ -624,6 +625,7 @@ export type AppClassProperties = { insertEmbeddableElement: App["insertEmbeddableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; getName: App["getName"]; + dismissLinearEditor: App["dismissLinearEditor"]; }; export type PointerDownState = Readonly<{ diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 82d86dabe5..c8e8c743df 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1157,3 +1157,6 @@ export const promiseTry = async ( resolve(fn(...args)); }); }; + +export const isAnyTrue = (...args: boolean[]): boolean => + Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0; diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap index 4c2ac7b379..613b645908 100644 --- a/packages/utils/__snapshots__/export.test.ts.snap +++ b/packages/utils/__snapshots__/export.test.ts.snap @@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "contextMenu": null, "currentChartType": "bar", "currentHoveredFontFamily": null, + "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", "currentItemEndArrowhead": "arrow", "currentItemFillStyle": "solid", diff --git a/packages/utils/geometry/geometry.ts b/packages/utils/geometry/geometry.ts index 4216fe0eb2..d9bf2aecd3 100644 --- a/packages/utils/geometry/geometry.ts +++ b/packages/utils/geometry/geometry.ts @@ -16,10 +16,22 @@ const DEFAULT_THRESHOLD = 10e-5; */ // the two vectors are ao and bo -export const cross = (a: Point, b: Point, o: Point) => { +export const cross = ( + a: Readonly, + b: Readonly, + o: Readonly, +) => { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }; +export const dot = ( + a: Readonly, + b: Readonly, + o: Readonly, +) => { + return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]); +}; + export const isClosed = (polygon: Polygon) => { const first = polygon[0]; const last = polygon[polygon.length - 1]; @@ -36,7 +48,9 @@ export const close = (polygon: Polygon) => { // convert radians to degress export const angleToDegrees = (angle: number) => { - return (angle * 180) / Math.PI; + const theta = (angle * 180) / Math.PI; + + return theta < 0 ? 360 + theta : theta; }; // convert degrees to radians