diff --git a/src/actions/actionToggleGridMode.tsx b/src/actions/actionToggleGridMode.tsx index 8841cad5a8c4d..e4f930bff146f 100644 --- a/src/actions/actionToggleGridMode.tsx +++ b/src/actions/actionToggleGridMode.tsx @@ -15,6 +15,7 @@ export const actionToggleGridMode = register({ appState: { ...appState, gridSize: this.checked!(appState) ? null : GRID_SIZE, + objectsSnapModeEnabled: false, }, commitToHistory: false, }; diff --git a/src/actions/actionToggleObjectsSnapMode.tsx b/src/actions/actionToggleObjectsSnapMode.tsx new file mode 100644 index 0000000000000..60986137b278b --- /dev/null +++ b/src/actions/actionToggleObjectsSnapMode.tsx @@ -0,0 +1,28 @@ +import { CODES, KEYS } from "../keys"; +import { register } from "./register"; + +export const actionToggleObjectsSnapMode = register({ + name: "objectsSnapMode", + viewMode: true, + trackEvent: { + category: "canvas", + predicate: (appState) => !appState.objectsSnapModeEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + objectsSnapModeEnabled: !this.checked!(appState), + gridSize: null, + }, + commitToHistory: false, + }; + }, + checked: (appState) => appState.objectsSnapModeEnabled, + predicate: (elements, appState, appProps) => { + return typeof appProps.objectsSnapModeEnabled === "undefined"; + }, + contextItemLabel: "buttons.objectsSnapMode", + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S, +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index 9b53f81731d04..1e72aa48ab91f 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -80,6 +80,7 @@ export { export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleZenMode } from "./actionToggleZenMode"; +export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index b9c24a757244a..20ab9f7b447e9 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -28,6 +28,7 @@ export type ShortcutName = | "ungroup" | "gridMode" | "zenMode" + | "objectsSnapMode" | "stats" | "addToLibrary" | "viewMode" @@ -74,6 +75,7 @@ const shortcutMap: Record = { ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], gridMode: [getShortcutKey("CtrlOrCmd+'")], zenMode: [getShortcutKey("Alt+Z")], + objectsSnapMode: [getShortcutKey("Alt+S")], stats: [getShortcutKey("Alt+/")], addToLibrary: [], flipHorizontal: [getShortcutKey("Shift+H")], diff --git a/src/actions/types.ts b/src/actions/types.ts index 9dea7974acb1f..c74e19552ca9a 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -51,6 +51,7 @@ export type ActionName = | "pasteStyles" | "gridMode" | "zenMode" + | "objectsSnapMode" | "stats" | "changeStrokeColor" | "changeBackgroundColor" diff --git a/src/appState.ts b/src/appState.ts index 65d8f2f9c74c5..6b34850ac8747 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -100,6 +100,12 @@ export const getDefaultAppState = (): Omit< pendingImageElementId: null, showHyperlinkPopup: false, selectedLinearElement: null, + snapLines: [], + originSnapOffset: { + x: 0, + y: 0, + }, + objectsSnapModeEnabled: false, }; }; @@ -207,6 +213,9 @@ const APP_STATE_STORAGE_CONF = (< pendingImageElementId: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false }, + snapLines: { browser: false, export: false, server: false }, + originSnapOffset: { browser: false, export: false, server: false }, + objectsSnapModeEnabled: { browser: true, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/src/components/App.tsx b/src/components/App.tsx index d27e4354e0751..9183d57c2990e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -35,6 +35,7 @@ import { actionLink, actionToggleElementLock, actionToggleLinearEditor, + actionToggleObjectsSnapMode, } from "../actions"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { ActionManager } from "../actions/manager"; @@ -229,6 +230,7 @@ import { FrameNameBoundsCache, SidebarName, SidebarTabName, + KeyboardModifiersObject, } from "../types"; import { debounce, @@ -343,6 +345,17 @@ import { import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; +import { + getSnapLinesAtPointer, + snapDraggedElements, + isActiveToolNonLinearSnappable, + snapNewElement, + snapResizingElements, + isSnappingEnabled, + getVisibleGaps, + getReferenceSnapPoints, + SnapCache, +} from "../snapping"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { activeEyeDropperAtom } from "./EyeDropper"; @@ -516,6 +529,7 @@ class App extends React.Component { viewModeEnabled = false, zenModeEnabled = false, gridModeEnabled = false, + objectsSnapModeEnabled = false, theme = defaultAppState.theme, name = defaultAppState.name, } = props; @@ -526,6 +540,7 @@ class App extends React.Component { ...this.getCanvasOffsets(), viewModeEnabled, zenModeEnabled, + objectsSnapModeEnabled, gridSize: gridModeEnabled ? GRID_SIZE : null, name, width: window.innerWidth, @@ -1765,6 +1780,7 @@ class App extends React.Component { this.scene.destroy(); this.library.destroy(); ShapeCache.destroy(); + SnapCache.destroy(); clearRenderCache(); this.onChangeEmitter.destroy(); @@ -1789,7 +1805,6 @@ class App extends React.Component { this.nearestScrollableContainer = undefined; this.excalidrawContainerValue = { container: null, id: "unmounted" }; - clearTimeout(touchTimeout); isSomeElementSelected.clearCache(); selectGroupsForSelectedElements.clearCache(); touchTimeout = 0; @@ -3209,15 +3224,21 @@ class App extends React.Component { this.onImageAction(); } if (nextActiveTool.type !== "selection") { - this.setState({ + this.setState((prevState) => ({ activeTool: nextActiveTool, selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, + snapLines: [], + originSnapOffset: null, + })); + } else { + this.setState({ + activeTool: nextActiveTool, + snapLines: [], + originSnapOffset: null, activeEmbeddable: null, }); - } else { - this.setState({ activeTool: nextActiveTool, activeEmbeddable: null }); } }; @@ -3954,6 +3975,30 @@ class App extends React.Component { const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; + if ( + !this.state.draggingElement && + isActiveToolNonLinearSnappable(this.state.activeTool.type) + ) { + const { originOffset, snapLines } = getSnapLinesAtPointer( + this.scene.getNonDeletedElements(), + this.state, + { + x: scenePointerX, + y: scenePointerY, + }, + event, + ); + + this.setState({ + snapLines, + originSnapOffset: originOffset, + }); + } else if (!this.state.draggingElement) { + this.setState({ + snapLines: [], + }); + } + if ( this.state.editingLinearElement && !this.state.editingLinearElement.isDragging @@ -4424,6 +4469,10 @@ class App extends React.Component { this.setState({ contextMenu: null }); } + if (this.state.snapLines) { + this.setAppState({ snapLines: [] }); + } + this.updateGestureOnPointerDown(event); // if dragging element is freedraw and another pointerdown event occurs @@ -5714,6 +5763,52 @@ class App extends React.Component { }); }; + private maybeCacheReferenceSnapPoints( + event: KeyboardModifiersObject, + selectedElements: ExcalidrawElement[], + recomputeAnyways: boolean = false, + ) { + if ( + isSnappingEnabled({ + event, + appState: this.state, + selectedElements, + }) && + (recomputeAnyways || !SnapCache.getReferenceSnapPoints()) + ) { + SnapCache.setReferenceSnapPoints( + getReferenceSnapPoints( + this.scene.getNonDeletedElements(), + selectedElements, + this.state, + ), + ); + } + } + + private maybeCacheVisibleGaps( + event: KeyboardModifiersObject, + selectedElements: ExcalidrawElement[], + recomputeAnyways: boolean = false, + ) { + if ( + isSnappingEnabled({ + event, + appState: this.state, + selectedElements, + }) && + (recomputeAnyways || !SnapCache.getVisibleGaps()) + ) { + SnapCache.setVisibleGaps( + getVisibleGaps( + this.scene.getNonDeletedElements(), + selectedElements, + this.state, + ), + ); + } + } + private onKeyDownFromPointerDownHandler( pointerDownState: PointerDownState, ): (event: KeyboardEvent) => void { @@ -5943,33 +6038,62 @@ class App extends React.Component { !this.state.editingElement && this.state.activeEmbeddable?.state !== "active" ) { - const [dragX, dragY] = getGridPoint( - pointerCoords.x - pointerDownState.drag.offset.x, - pointerCoords.y - pointerDownState.drag.offset.y, - event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, - ); + const dragOffset = { + x: pointerCoords.x - pointerDownState.origin.x, + y: pointerCoords.y - pointerDownState.origin.y, + }; - const [dragDistanceX, dragDistanceY] = [ - Math.abs(pointerCoords.x - pointerDownState.origin.x), - Math.abs(pointerCoords.y - pointerDownState.origin.y), + const originalElements = [ + ...pointerDownState.originalElements.values(), ]; // We only drag in one direction if shift is pressed const lockDirection = event.shiftKey; + + if (lockDirection) { + const distanceX = Math.abs(dragOffset.x); + const distanceY = Math.abs(dragOffset.y); + + const lockX = lockDirection && distanceX < distanceY; + const lockY = lockDirection && distanceX > distanceY; + + if (lockX) { + dragOffset.x = 0; + } + + if (lockY) { + dragOffset.y = 0; + } + } + + // Snap cache *must* be synchronously popuplated before initial drag, + // otherwise the first drag even will not snap, causing a jump before + // it snaps to its position if previously snapped already. + this.maybeCacheVisibleGaps(event, selectedElements); + this.maybeCacheReferenceSnapPoints(event, selectedElements); + + const { snapOffset, snapLines } = snapDraggedElements( + getSelectedElements(originalElements, this.state), + dragOffset, + this.state, + event, + ); + + this.setState({ snapLines }); + // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input !this.state.editingFrame && dragSelectedElements( pointerDownState, selectedElements, - dragX, - dragY, - lockDirection, - dragDistanceX, - dragDistanceY, + dragOffset, this.state, this.scene, + snapOffset, + event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, ); + this.maybeSuggestBindingForAll(selectedElements); // We duplicate the selected element if alt is pressed on pointer move @@ -6010,15 +6134,21 @@ class App extends React.Component { groupIdMap, element, ); - const [originDragX, originDragY] = getGridPoint( - pointerDownState.origin.x - pointerDownState.drag.offset.x, - pointerDownState.origin.y - pointerDownState.drag.offset.y, - event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, - ); + const origElement = pointerDownState.originalElements.get( + element.id, + )!; mutateElement(duplicatedElement, { - x: duplicatedElement.x + (originDragX - dragX), - y: duplicatedElement.y + (originDragY - dragY), + x: origElement.x, + y: origElement.y, }); + + // put duplicated element to pointerDownState.originalElements + // so that we can snap to the duplicated element without releasing + pointerDownState.originalElements.set( + duplicatedElement.id, + duplicatedElement, + ); + nextElements.push(duplicatedElement); elementsToAppend.push(element); oldIdToDuplicatedId.set(element.id, duplicatedElement.id); @@ -6044,6 +6174,8 @@ class App extends React.Component { oldIdToDuplicatedId, ); this.scene.replaceAllElements(nextSceneElements); + this.maybeCacheVisibleGaps(event, selectedElements, true); + this.maybeCacheReferenceSnapPoints(event, selectedElements, true); } return; } @@ -6260,6 +6392,7 @@ class App extends React.Component { isResizing, isRotating, } = this.state; + this.setState({ isResizing: false, isRotating: false, @@ -6274,8 +6407,14 @@ class App extends React.Component { multiElement || isTextElement(this.state.editingElement) ? this.state.editingElement : null, + snapLines: [], + + originSnapOffset: null, }); + SnapCache.setReferenceSnapPoints(null); + SnapCache.setVisibleGaps(null); + this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.setState({ @@ -7808,7 +7947,7 @@ class App extends React.Component { shouldResizeFromCenter(event), ); } else { - const [gridX, gridY] = getGridPoint( + let [gridX, gridY] = getGridPoint( pointerCoords.x, pointerCoords.y, event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, @@ -7822,6 +7961,33 @@ class App extends React.Component { ? image.width / image.height : null; + this.maybeCacheReferenceSnapPoints(event, [draggingElement]); + + const { snapOffset, snapLines } = snapNewElement( + draggingElement, + this.state, + event, + { + x: + pointerDownState.originInGrid.x + + (this.state.originSnapOffset?.x ?? 0), + y: + pointerDownState.originInGrid.y + + (this.state.originSnapOffset?.y ?? 0), + }, + { + x: gridX - pointerDownState.originInGrid.x, + y: gridY - pointerDownState.originInGrid.y, + }, + ); + + gridX += snapOffset.x; + gridY += snapOffset.y; + + this.setState({ + snapLines, + }); + dragNewElement( draggingElement, this.state.activeTool.type, @@ -7836,6 +8002,7 @@ class App extends React.Component { : shouldMaintainAspectRatio(event), shouldResizeFromCenter(event), aspectRatio, + this.state.originSnapOffset, ); this.maybeSuggestBindingForAll([draggingElement]); @@ -7877,7 +8044,7 @@ class App extends React.Component { activeEmbeddable: null, }); const pointerCoords = pointerDownState.lastCoords; - const [resizeX, resizeY] = getGridPoint( + let [resizeX, resizeY] = getGridPoint( pointerCoords.x - pointerDownState.resize.offset.x, pointerCoords.y - pointerDownState.resize.offset.y, event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, @@ -7905,6 +8072,41 @@ class App extends React.Component { }); }); + // check needed for avoiding flickering when a key gets pressed + // during dragging + if (!this.state.selectedElementsAreBeingDragged) { + const [gridX, gridY] = getGridPoint( + pointerCoords.x, + pointerCoords.y, + event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, + ); + + const dragOffset = { + x: gridX - pointerDownState.originInGrid.x, + y: gridY - pointerDownState.originInGrid.y, + }; + + const originalElements = [...pointerDownState.originalElements.values()]; + + this.maybeCacheReferenceSnapPoints(event, selectedElements); + + const { snapOffset, snapLines } = snapResizingElements( + selectedElements, + getSelectedElements(originalElements, this.state), + this.state, + event, + dragOffset, + transformHandleType, + ); + + resizeX += snapOffset.x; + resizeY += snapOffset.y; + + this.setState({ + snapLines, + }); + } + if ( transformElements( pointerDownState, @@ -7920,6 +8122,7 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, + this.state, ) ) { this.maybeSuggestBindingForAll(selectedElements); @@ -8007,6 +8210,7 @@ class App extends React.Component { actionUnlockAllElements, CONTEXT_MENU_SEPARATOR, actionToggleGridMode, + actionToggleObjectsSnapMode, actionToggleZenMode, actionToggleViewMode, actionToggleStats, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index b51fadfca61b3..3954839ea5e71 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -258,6 +258,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("buttons.zenMode")} shortcuts={[getShortcutKey("Alt+Z")]} /> + { + const [minX, minY, maxX, maxY] = getCommonBounds(elements); + return [ + minX + dragOffset.x, + minY + dragOffset.y, + maxX + dragOffset.x, + maxY + dragOffset.y, + ]; +}; + export const getResizedElementAbsoluteCoords = ( element: ExcalidrawElement, nextWidth: number, diff --git a/src/element/dragElements.ts b/src/element/dragElements.ts index 4dc5322ba8340..866a56c543dc4 100644 --- a/src/element/dragElements.ts +++ b/src/element/dragElements.ts @@ -6,23 +6,22 @@ import { NonDeletedExcalidrawElement } from "./types"; import { AppState, PointerDownState } from "../types"; import { getBoundTextElement } from "./textElement"; import { isSelectedViaGroup } from "../groups"; +import { getGridPoint } from "../math"; import Scene from "../scene/Scene"; import { isFrameElement } from "./typeChecks"; export const dragSelectedElements = ( pointerDownState: PointerDownState, selectedElements: NonDeletedExcalidrawElement[], - pointerX: number, - pointerY: number, - lockDirection: boolean = false, - distanceX: number = 0, - distanceY: number = 0, + offset: { x: number; y: number }, appState: AppState, scene: Scene, + snapOffset: { + x: number; + y: number; + }, + gridSize: AppState["gridSize"], ) => { - const [x1, y1] = getCommonBounds(selectedElements); - const offset = { x: pointerX - x1, y: pointerY - y1 }; - // 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 @@ -44,12 +43,11 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords( - lockDirection, - distanceX, - distanceY, pointerDownState, element, offset, + snapOffset, + gridSize, ); // update coords of bound text only if we're dragging the container directly // (we don't drag the group that it's part of) @@ -69,12 +67,11 @@ export const dragSelectedElements = ( (!textElement.frameId || !frames.includes(textElement.frameId)) ) { updateElementCoords( - lockDirection, - distanceX, - distanceY, pointerDownState, textElement, offset, + snapOffset, + gridSize, ); } } @@ -85,31 +82,40 @@ export const dragSelectedElements = ( }; const updateElementCoords = ( - lockDirection: boolean, - distanceX: number, - distanceY: number, pointerDownState: PointerDownState, element: NonDeletedExcalidrawElement, - offset: { x: number; y: number }, + dragOffset: { x: number; y: number }, + snapOffset: { x: number; y: number }, + gridSize: AppState["gridSize"], ) => { - let x: number; - let y: number; - if (lockDirection) { - const lockX = lockDirection && distanceX < distanceY; - const lockY = lockDirection && distanceX > distanceY; - const original = pointerDownState.originalElements.get(element.id); - x = lockX && original ? original.x : element.x + offset.x; - y = lockY && original ? original.y : element.y + offset.y; - } else { - x = element.x + offset.x; - y = element.y + offset.y; + const originalElement = + pointerDownState.originalElements.get(element.id) ?? element; + + let nextX = originalElement.x + dragOffset.x + snapOffset.x; + let nextY = originalElement.y + dragOffset.y + snapOffset.y; + + if (snapOffset.x === 0 || snapOffset.y === 0) { + const [nextGridX, nextGridY] = getGridPoint( + originalElement.x + dragOffset.x, + originalElement.y + dragOffset.y, + gridSize, + ); + + if (snapOffset.x === 0) { + nextX = nextGridX; + } + + if (snapOffset.y === 0) { + nextY = nextGridY; + } } mutateElement(element, { - x, - y, + x: nextX, + y: nextY, }); }; + export const getDragOffsetXY = ( selectedElements: NonDeletedExcalidrawElement[], x: number, @@ -133,6 +139,10 @@ export const dragNewElement = ( /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is true */ widthAspectRatio?: number | null, + originOffset: { + x: number; + y: number; + } | null = null, ) => { if (shouldMaintainAspectRatio && draggingElement.type !== "selection") { if (widthAspectRatio) { @@ -173,8 +183,8 @@ export const dragNewElement = ( if (width !== 0 && height !== 0) { mutateElement(draggingElement, { - x: newX, - y: newY, + x: newX + (originOffset?.x ?? 0), + y: newY + (originOffset?.y ?? 0), width, height, }); diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 136c70fb8c85a..b4974848e909a 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -41,7 +41,7 @@ import { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import { Point, PointerDownState } from "../types"; +import { AppState, Point, PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -79,6 +79,7 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, + appState: AppState, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -466,8 +467,8 @@ export const resizeSingleElement = ( boundTextElement.fontSize, boundTextElement.lineHeight, ); - eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); - eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); + eleNewWidth = Math.max(eleNewWidth, minWidth); + eleNewHeight = Math.max(eleNewHeight, minHeight); } } @@ -508,8 +509,11 @@ export const resizeSingleElement = ( } } + const flipX = eleNewWidth < 0; + const flipY = eleNewHeight < 0; + // Flip horizontally - if (eleNewWidth < 0) { + if (flipX) { if (transformHandleDirection.includes("e")) { newTopLeft[0] -= Math.abs(newBoundsWidth); } @@ -517,8 +521,9 @@ export const resizeSingleElement = ( newTopLeft[0] += Math.abs(newBoundsWidth); } } + // Flip vertically - if (eleNewHeight < 0) { + if (flipY) { if (transformHandleDirection.includes("s")) { newTopLeft[1] -= Math.abs(newBoundsHeight); } @@ -542,10 +547,20 @@ export const resizeSingleElement = ( const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner + // So we need to readjust (x,y) to be where the first point should be + const newOrigin = [...newTopLeft]; + const linearElementXOffset = stateAtResizeStart.x - newBoundsX1; + const linearElementYOffset = stateAtResizeStart.y - newBoundsY1; + newOrigin[0] += linearElementXOffset; + newOrigin[1] += linearElementYOffset; + + const nextX = newOrigin[0]; + const nextY = newOrigin[1]; + // Readjust points for linear elements let rescaledElementPointsY; let rescaledPoints; - if (isLinearElement(element) || isFreeDrawElement(element)) { rescaledElementPointsY = rescalePoints( 1, @@ -562,16 +577,11 @@ export const resizeSingleElement = ( ); } - // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner - // So we need to readjust (x,y) to be where the first point should be - const newOrigin = [...newTopLeft]; - newOrigin[0] += stateAtResizeStart.x - newBoundsX1; - newOrigin[1] += stateAtResizeStart.y - newBoundsY1; const resizedElement = { width: Math.abs(eleNewWidth), height: Math.abs(eleNewHeight), - x: newOrigin[0], - y: newOrigin[1], + x: nextX, + y: nextY, points: rescaledPoints, }; @@ -680,6 +690,10 @@ export const resizeMultipleElements = ( const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox( targetElements.map(({ orig }) => orig).concat(boundTextElements), ); + + // const originalHeight = maxY - minY; + // const originalWidth = maxX - minX; + const direction = transformHandleType; const mapDirectionsToAnchors: Record = { diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index fc1e8cf2e4440..9115d0e599f86 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -957,7 +957,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 85, - 4.5, + 4.999999999999986, ] `); @@ -1002,8 +1002,8 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ - 375, - -539, + 374.99999999999994, + -535.0000000000001, ] `); }); @@ -1190,7 +1190,7 @@ describe("textWysiwyg", () => { editor.blur(); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); - expect(rectangle.height).toBe(156); + expect(rectangle.height).toBeCloseTo(155, 8); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); mouse.select(rectangle); @@ -1200,9 +1200,12 @@ describe("textWysiwyg", () => { await new Promise((r) => setTimeout(r, 0)); editor.blur(); - expect(rectangle.height).toBe(156); + expect(rectangle.height).toBeCloseTo(155, 8); // cache updated again - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo( + 155, + 8, + ); }); it("should reset the container height cache when font properties updated", async () => { diff --git a/src/keys.ts b/src/keys.ts index da3e6b49da62b..f7bf54db5e4c7 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -21,6 +21,7 @@ export const CODES = { V: "KeyV", Z: "KeyZ", R: "KeyR", + S: "KeyS", } as const; export const KEYS = { diff --git a/src/locales/en.json b/src/locales/en.json index c5ce67f44b44e..b9c32bff8c62d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -166,6 +166,7 @@ "darkMode": "Dark mode", "lightMode": "Light mode", "zenMode": "Zen mode", + "objectsSnapMode": "Snap to objects", "exitZenMode": "Exit zen mode", "cancel": "Cancel", "clear": "Clear", diff --git a/src/math.test.ts b/src/math.test.ts index 60ab56184e761..eb5392eed273f 100644 --- a/src/math.test.ts +++ b/src/math.test.ts @@ -1,4 +1,4 @@ -import { rotate } from "./math"; +import { rangeIntersection, rangesOverlap, rotate } from "./math"; describe("rotate", () => { it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { @@ -13,3 +13,43 @@ describe("rotate", () => { expect(res2).toEqual([x1, x2]); }); }); + +describe("range overlap", () => { + it("should overlap when range a contains range b", () => { + expect(rangesOverlap([1, 4], [2, 3])).toBe(true); + expect(rangesOverlap([1, 4], [1, 4])).toBe(true); + expect(rangesOverlap([1, 4], [1, 3])).toBe(true); + expect(rangesOverlap([1, 4], [2, 4])).toBe(true); + }); + + it("should overlap when range b contains range a", () => { + expect(rangesOverlap([2, 3], [1, 4])).toBe(true); + expect(rangesOverlap([1, 3], [1, 4])).toBe(true); + expect(rangesOverlap([2, 4], [1, 4])).toBe(true); + }); + + it("should overlap when range a and b intersect", () => { + expect(rangesOverlap([1, 4], [2, 5])).toBe(true); + }); +}); + +describe("range intersection", () => { + it("should intersect completely with itself", () => { + expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]); + }); + + it("should intersect irrespective of order", () => { + expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]); + expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]); + expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]); + expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]); + }); + + it("should intersect at the edge", () => { + expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]); + }); + + it("should not intersect", () => { + expect(rangeIntersection([1, 4], [5, 7])).toEqual(null); + }); +}); diff --git a/src/math.ts b/src/math.ts index f549a6af1658a..376052095b356 100644 --- a/src/math.ts +++ b/src/math.ts @@ -472,3 +472,36 @@ export const isRightAngle = (angle: number) => { // angle, which we can check with modulo after rounding. return Math.round((angle / Math.PI) * 10000) % 5000 === 0; }; + +// Given two ranges, return if the two ranges overlap with each other +// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5] +export const rangesOverlap = ( + [a0, a1]: [number, number], + [b0, b1]: [number, number], +) => { + if (a0 <= b0) { + return a1 >= b0; + } + + if (a0 >= b0) { + return b1 >= a0; + } + + return false; +}; + +// Given two ranges,return ther intersection of the two ranges if any +// e.g. the intersection of [1, 3] and [2, 4] is [2, 3] +export const rangeIntersection = ( + rangeA: [number, number], + rangeB: [number, number], +): [number, number] | null => { + const rangeStart = Math.max(rangeA[0], rangeB[0]); + const rangeEnd = Math.min(rangeA[1], rangeB[1]); + + if (rangeStart <= rangeEnd) { + return [rangeStart, rangeEnd]; + } + + return null; +}; diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 045a2b9e3a21c..190df0e955400 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -67,6 +67,7 @@ import { EXTERNAL_LINK_IMG, getLinkHandleFromCoords, } from "../element/Hyperlink"; +import { renderSnaps } from "./renderSnaps"; import { isEmbeddableElement, isFrameElement, @@ -723,6 +724,8 @@ const _renderInteractiveScene = ({ context.restore(); } + renderSnaps(context, appState); + // Reset zoom context.restore(); diff --git a/src/renderer/renderSnaps.ts b/src/renderer/renderSnaps.ts new file mode 100644 index 0000000000000..220c3e7db4dba --- /dev/null +++ b/src/renderer/renderSnaps.ts @@ -0,0 +1,189 @@ +import { PointSnapLine, PointerSnapLine } from "../snapping"; +import { InteractiveCanvasAppState, Point } from "../types"; + +const SNAP_COLOR_LIGHT = "#ff6b6b"; +const SNAP_COLOR_DARK = "#ff0000"; +const SNAP_WIDTH = 1; +const SNAP_CROSS_SIZE = 2; + +export const renderSnaps = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + if (!appState.snapLines.length) { + return; + } + + // in dark mode, we need to adjust the color to account for color inversion. + // Don't change if zen mode, because we draw only crosses, we want the + // colors to be more visible + const snapColor = + appState.theme === "light" || appState.zenModeEnabled + ? SNAP_COLOR_LIGHT + : SNAP_COLOR_DARK; + // in zen mode make the cross more visible since we don't draw the lines + const snapWidth = + (appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) / + appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + for (const snapLine of appState.snapLines) { + if (snapLine.type === "pointer") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + + drawPointerSnapLine(snapLine, context, appState); + } else if (snapLine.type === "gap") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + + drawGapLine( + snapLine.points[0], + snapLine.points[1], + snapLine.direction, + appState, + context, + ); + } else if (snapLine.type === "points") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + drawPointsSnapLine(snapLine, context, appState); + } + } + + context.restore(); +}; + +const drawPointsSnapLine = ( + pointSnapLine: PointSnapLine, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + if (!appState.zenModeEnabled) { + const firstPoint = pointSnapLine.points[0]; + const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1]; + + drawLine(firstPoint, lastPoint, context); + } + + for (const point of pointSnapLine.points) { + drawCross(point, appState, context); + } +}; + +const drawPointerSnapLine = ( + pointerSnapLine: PointerSnapLine, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + drawCross(pointerSnapLine.points[0], appState, context); + if (!appState.zenModeEnabled) { + drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context); + } +}; + +const drawCross = ( + [x, y]: Point, + appState: InteractiveCanvasAppState, + context: CanvasRenderingContext2D, +) => { + context.save(); + const size = + (appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) / + appState.zoom.value; + context.beginPath(); + + context.moveTo(x - size, y - size); + context.lineTo(x + size, y + size); + + context.moveTo(x + size, y - size); + context.lineTo(x - size, y + size); + + context.stroke(); + context.restore(); +}; + +const drawLine = ( + from: Point, + to: Point, + context: CanvasRenderingContext2D, +) => { + context.beginPath(); + context.lineTo(...from); + context.lineTo(...to); + context.stroke(); +}; + +const drawGapLine = ( + from: Point, + to: Point, + direction: "horizontal" | "vertical", + appState: InteractiveCanvasAppState, + context: CanvasRenderingContext2D, +) => { + // a horizontal gap snap line + // |–––––––||–––––––| + // ^ ^ ^ ^ + // \ \ \ \ + // (1) (2) (3) (4) + + const FULL = 8 / appState.zoom.value; + const HALF = FULL / 2; + const QUARTER = FULL / 4; + + if (direction === "horizontal") { + const halfPoint = [(from[0] + to[0]) / 2, from[1]]; + // (1) + if (!appState.zenModeEnabled) { + drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context); + } + + // (3) + drawLine( + [halfPoint[0] - QUARTER, halfPoint[1] - HALF], + [halfPoint[0] - QUARTER, halfPoint[1] + HALF], + context, + ); + drawLine( + [halfPoint[0] + QUARTER, halfPoint[1] - HALF], + [halfPoint[0] + QUARTER, halfPoint[1] + HALF], + context, + ); + + if (!appState.zenModeEnabled) { + // (4) + drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context); + + // (2) + drawLine(from, to, context); + } + } else { + const halfPoint = [from[0], (from[1] + to[1]) / 2]; + // (1) + if (!appState.zenModeEnabled) { + drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context); + } + + // (3) + drawLine( + [halfPoint[0] - HALF, halfPoint[1] - QUARTER], + [halfPoint[0] + HALF, halfPoint[1] - QUARTER], + context, + ); + drawLine( + [halfPoint[0] - HALF, halfPoint[1] + QUARTER], + [halfPoint[0] + HALF, halfPoint[1] + QUARTER], + context, + ); + + if (!appState.zenModeEnabled) { + // (4) + drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context); + + // (2) + drawLine(from, to, context); + } + } +}; diff --git a/src/scene/selection.ts b/src/scene/selection.ts index e678894ab8787..dce7c9cd54233 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -11,6 +11,7 @@ import { getFrameElements, } from "../frame"; import { isShallowEqual } from "../utils"; +import { isElementInViewport } from "../element/sizeHelpers"; /** * Frames and their containing elements are not to be selected at the same time. @@ -89,6 +90,26 @@ export const getElementsWithinSelection = ( return elementsInSelection; }; +export const getVisibleAndNonSelectedElements = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElements: readonly NonDeletedExcalidrawElement[], + appState: AppState, +) => { + const selectedElementsSet = new Set( + selectedElements.map((element) => element.id), + ); + return elements.filter((element) => { + const isVisible = isElementInViewport( + element, + appState.width, + appState.height, + appState, + ); + + return !selectedElementsSet.has(element.id) && isVisible; + }); +}; + // FIXME move this into the editor instance to keep utility methods stateless export const isSomeElementSelected = (function () { let lastElements: readonly NonDeletedExcalidrawElement[] | null = null; diff --git a/src/snapping.ts b/src/snapping.ts new file mode 100644 index 0000000000000..d3617e80241a5 --- /dev/null +++ b/src/snapping.ts @@ -0,0 +1,1361 @@ +import { + Bounds, + getCommonBounds, + getDraggedElementsBounds, + getElementAbsoluteCoords, +} from "./element/bounds"; +import { MaybeTransformHandleType } from "./element/transformHandles"; +import { isBoundToContainer, isFrameElement } from "./element/typeChecks"; +import { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "./element/types"; +import { getMaximumGroups } from "./groups"; +import { KEYS } from "./keys"; +import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; +import { getVisibleAndNonSelectedElements } from "./scene/selection"; +import { AppState, KeyboardModifiersObject, Point } from "./types"; + +const SNAP_DISTANCE = 8; + +// do not comput more gaps per axis than this limit +// TODO increase or remove once we optimize +const VISIBLE_GAPS_LIMIT_PER_AXIS = 99999; + +// snap distance with zoom value taken into consideration +export const getSnapDistance = (zoomValue: number) => { + return SNAP_DISTANCE / zoomValue; +}; + +type Vector2D = { + x: number; + y: number; +}; + +type PointPair = [Point, Point]; + +export type PointSnap = { + type: "point"; + points: PointPair; + offset: number; +}; + +export type Gap = { + // start side ↓ length + // ┌───────────┐◄───────────────► + // │ │-----------------┌───────────┐ + // │ start │ ↑ │ │ + // │ element │ overlap │ end │ + // │ │ ↓ │ element │ + // └───────────┘-----------------│ │ + // └───────────┘ + // ↑ end side + startBounds: Bounds; + endBounds: Bounds; + startSide: [Point, Point]; + endSide: [Point, Point]; + overlap: [number, number]; + length: number; +}; + +export type GapSnap = { + type: "gap"; + direction: + | "center_horizontal" + | "center_vertical" + | "side_left" + | "side_right" + | "side_top" + | "side_bottom"; + gap: Gap; + offset: number; +}; + +export type GapSnaps = GapSnap[]; + +export type Snap = GapSnap | PointSnap; +export type Snaps = Snap[]; + +export type PointSnapLine = { + type: "points"; + points: Point[]; +}; + +export type PointerSnapLine = { + type: "pointer"; + points: PointPair; + direction: "horizontal" | "vertical"; +}; + +export type GapSnapLine = { + type: "gap"; + direction: "horizontal" | "vertical"; + points: PointPair; +}; + +export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine; + +// ----------------------------------------------------------------------------- + +export class SnapCache { + private static referenceSnapPoints: Point[] | null = null; + + private static visibleGaps: { + verticalGaps: Gap[]; + horizontalGaps: Gap[]; + } | null = null; + + public static setReferenceSnapPoints = (snapPoints: Point[] | null) => { + SnapCache.referenceSnapPoints = snapPoints; + }; + + public static getReferenceSnapPoints = () => { + return SnapCache.referenceSnapPoints; + }; + + public static setVisibleGaps = ( + gaps: { + verticalGaps: Gap[]; + horizontalGaps: Gap[]; + } | null, + ) => { + SnapCache.visibleGaps = gaps; + }; + + public static getVisibleGaps = () => { + return SnapCache.visibleGaps; + }; + + public static destroy = () => { + SnapCache.referenceSnapPoints = null; + SnapCache.visibleGaps = null; + }; +} + +// ----------------------------------------------------------------------------- + +export const isSnappingEnabled = ({ + event, + appState, + selectedElements, +}: { + appState: AppState; + event: KeyboardModifiersObject; + selectedElements: NonDeletedExcalidrawElement[]; +}) => { + if (event) { + return ( + (appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) || + (!appState.objectsSnapModeEnabled && + event[KEYS.CTRL_OR_CMD] && + appState.gridSize === null) + ); + } + + // do not suggest snaps for an arrow to give way to binding + if (selectedElements.length === 1 && selectedElements[0].type === "arrow") { + return false; + } + return appState.objectsSnapModeEnabled; +}; + +export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { + return Math.abs(a - b) <= precision; +}; + +export const getElementsCorners = ( + elements: ExcalidrawElement[], + { + omitCenter, + boundingBoxCorners, + dragOffset, + }: { + omitCenter?: boolean; + boundingBoxCorners?: boolean; + dragOffset?: Vector2D; + } = { + omitCenter: false, + boundingBoxCorners: false, + }, +): Point[] => { + let result: Point[] = []; + + if (elements.length === 1) { + const element = elements[0]; + + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + + if (dragOffset) { + x1 += dragOffset.x; + x2 += dragOffset.x; + cx += dragOffset.x; + + y1 += dragOffset.y; + y2 += dragOffset.y; + cy += dragOffset.y; + } + + const halfWidth = (x2 - x1) / 2; + const halfHeight = (y2 - y1) / 2; + + if ( + (element.type === "diamond" || element.type === "ellipse") && + !boundingBoxCorners + ) { + const leftMid = rotatePoint( + [x1, y1 + halfHeight], + [cx, cy], + element.angle, + ); + const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle); + const rightMid = rotatePoint( + [x2, y1 + halfHeight], + [cx, cy], + element.angle, + ); + const bottomMid = rotatePoint( + [x1 + halfWidth, y2], + [cx, cy], + element.angle, + ); + const center: Point = [cx, cy]; + + result = omitCenter + ? [leftMid, topMid, rightMid, bottomMid] + : [leftMid, topMid, rightMid, bottomMid, center]; + } else { + const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle); + const topRight = rotatePoint([x2, y1], [cx, cy], element.angle); + const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle); + const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle); + const center: Point = [cx, cy]; + + result = omitCenter + ? [topLeft, topRight, bottomLeft, bottomRight] + : [topLeft, topRight, bottomLeft, bottomRight, center]; + } + } else if (elements.length > 1) { + const [minX, minY, maxX, maxY] = getDraggedElementsBounds( + elements, + dragOffset ?? { x: 0, y: 0 }, + ); + const width = maxX - minX; + const height = maxY - minY; + + const topLeft: Point = [minX, minY]; + const topRight: Point = [maxX, minY]; + const bottomLeft: Point = [minX, maxY]; + const bottomRight: Point = [maxX, maxY]; + const center: Point = [minX + width / 2, minY + height / 2]; + + result = omitCenter + ? [topLeft, topRight, bottomLeft, bottomRight] + : [topLeft, topRight, bottomLeft, bottomRight, center]; + } + + return result.map((point) => [round(point[0]), round(point[1])] as Point); +}; + +const getReferenceElements = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElements: NonDeletedExcalidrawElement[], + appState: AppState, +) => { + const selectedFrames = selectedElements + .filter((element) => isFrameElement(element)) + .map((frame) => frame.id); + + return getVisibleAndNonSelectedElements( + elements, + selectedElements, + appState, + ).filter( + (element) => !(element.frameId && selectedFrames.includes(element.frameId)), + ); +}; + +export const getVisibleGaps = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElements: ExcalidrawElement[], + appState: AppState, +) => { + const referenceElements: ExcalidrawElement[] = getReferenceElements( + elements, + selectedElements, + appState, + ); + + const referenceBounds = getMaximumGroups(referenceElements) + .filter( + (elementsGroup) => + !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), + ) + .map( + (group) => + getCommonBounds(group).map((bound) => + round(bound), + ) as unknown as Bounds, + ); + + const horizontallySorted = referenceBounds.sort((a, b) => a[0] - b[0]); + + const horizontalGaps: Gap[] = []; + + let c = 0; + + horizontal: for (let i = 0; i < horizontallySorted.length; i++) { + const startBounds = horizontallySorted[i]; + + for (let j = i + 1; j < horizontallySorted.length; j++) { + if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) { + break horizontal; + } + + const endBounds = horizontallySorted[j]; + + const [, startMinY, startMaxX, startMaxY] = startBounds; + const [endMinX, endMinY, , endMaxY] = endBounds; + + if ( + startMaxX < endMinX && + rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY]) + ) { + horizontalGaps.push({ + startBounds, + endBounds, + startSide: [ + [startMaxX, startMinY], + [startMaxX, startMaxY], + ], + endSide: [ + [endMinX, endMinY], + [endMinX, endMaxY], + ], + length: endMinX - startMaxX, + overlap: rangeIntersection( + [startMinY, startMaxY], + [endMinY, endMaxY], + )!, + }); + } + } + } + + const verticallySorted = referenceBounds.sort((a, b) => a[1] - b[1]); + + const verticalGaps: Gap[] = []; + + c = 0; + + vertical: for (let i = 0; i < verticallySorted.length; i++) { + const startBounds = verticallySorted[i]; + + for (let j = i + 1; j < verticallySorted.length; j++) { + if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) { + break vertical; + } + const endBounds = verticallySorted[j]; + + const [startMinX, , startMaxX, startMaxY] = startBounds; + const [endMinX, endMinY, endMaxX] = endBounds; + + if ( + startMaxY < endMinY && + rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX]) + ) { + verticalGaps.push({ + startBounds, + endBounds, + startSide: [ + [startMinX, startMaxY], + [startMaxX, startMaxY], + ], + endSide: [ + [endMinX, endMinY], + [endMaxX, endMinY], + ], + length: endMinY - startMaxY, + overlap: rangeIntersection( + [startMinX, startMaxX], + [endMinX, endMaxX], + )!, + }); + } + } + } + + return { + horizontalGaps, + verticalGaps, + }; +}; + +const getGapSnaps = ( + selectedElements: ExcalidrawElement[], + dragOffset: Vector2D, + appState: AppState, + event: KeyboardModifiersObject, + nearestSnapsX: Snaps, + nearestSnapsY: Snaps, + minOffset: Vector2D, +) => { + if (!isSnappingEnabled({ appState, event, selectedElements })) { + return []; + } + + if (selectedElements.length === 0) { + return []; + } + + const visibleGaps = SnapCache.getVisibleGaps(); + + if (visibleGaps) { + const { horizontalGaps, verticalGaps } = visibleGaps; + + const [minX, minY, maxX, maxY] = getDraggedElementsBounds( + selectedElements, + dragOffset, + ).map((bound) => round(bound)); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + for (const gap of horizontalGaps) { + if (!rangesOverlap([minY, maxY], gap.overlap)) { + continue; + } + + // center gap + const gapMidX = gap.startSide[0][0] + gap.length / 2; + const centerOffset = round(gapMidX - centerX); + const gapIsLargerThanSelection = gap.length > maxX - minX; + + if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) { + if (Math.abs(centerOffset) < minOffset.x) { + nearestSnapsX.length = 0; + } + minOffset.x = Math.abs(centerOffset); + + const snap: GapSnap = { + type: "gap", + direction: "center_horizontal", + gap, + offset: centerOffset, + }; + + nearestSnapsX.push(snap); + continue; + } + + // side gap, from the right + const [, , endMaxX] = gap.endBounds; + const distanceToEndElementX = minX - endMaxX; + const sideOffsetRight = round(gap.length - distanceToEndElementX); + + if (Math.abs(sideOffsetRight) <= minOffset.x) { + if (Math.abs(sideOffsetRight) < minOffset.x) { + nearestSnapsX.length = 0; + } + minOffset.x = Math.abs(sideOffsetRight); + + const snap: GapSnap = { + type: "gap", + direction: "side_right", + gap, + offset: sideOffsetRight, + }; + nearestSnapsX.push(snap); + continue; + } + + // side gap, from the left + const [startMinX, , ,] = gap.startBounds; + const distanceToStartElementX = startMinX - maxX; + const sideOffsetLeft = round(distanceToStartElementX - gap.length); + + if (Math.abs(sideOffsetLeft) <= minOffset.x) { + if (Math.abs(sideOffsetLeft) < minOffset.x) { + nearestSnapsX.length = 0; + } + minOffset.x = Math.abs(sideOffsetLeft); + + const snap: GapSnap = { + type: "gap", + direction: "side_left", + gap, + offset: sideOffsetLeft, + }; + nearestSnapsX.push(snap); + continue; + } + } + for (const gap of verticalGaps) { + if (!rangesOverlap([minX, maxX], gap.overlap)) { + continue; + } + + // center gap + const gapMidY = gap.startSide[0][1] + gap.length / 2; + const centerOffset = round(gapMidY - centerY); + const gapIsLargerThanSelection = gap.length > maxY - minY; + + if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) { + if (Math.abs(centerOffset) < minOffset.y) { + nearestSnapsY.length = 0; + } + minOffset.y = Math.abs(centerOffset); + + const snap: GapSnap = { + type: "gap", + direction: "center_vertical", + gap, + offset: centerOffset, + }; + + nearestSnapsY.push(snap); + continue; + } + + // side gap, from the top + const [, startMinY, ,] = gap.startBounds; + const distanceToStartElementY = startMinY - maxY; + const sideOffsetTop = round(distanceToStartElementY - gap.length); + + if (Math.abs(sideOffsetTop) <= minOffset.y) { + if (Math.abs(sideOffsetTop) < minOffset.y) { + nearestSnapsY.length = 0; + } + minOffset.y = Math.abs(sideOffsetTop); + + const snap: GapSnap = { + type: "gap", + direction: "side_top", + gap, + offset: sideOffsetTop, + }; + nearestSnapsY.push(snap); + continue; + } + + // side gap, from the bottom + const [, , , endMaxY] = gap.endBounds; + const distanceToEndElementY = round(minY - endMaxY); + const sideOffsetBottom = gap.length - distanceToEndElementY; + + if (Math.abs(sideOffsetBottom) <= minOffset.y) { + if (Math.abs(sideOffsetBottom) < minOffset.y) { + nearestSnapsY.length = 0; + } + minOffset.y = Math.abs(sideOffsetBottom); + + const snap: GapSnap = { + type: "gap", + direction: "side_bottom", + gap, + offset: sideOffsetBottom, + }; + nearestSnapsY.push(snap); + continue; + } + } + } +}; + +export const getReferenceSnapPoints = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElements: ExcalidrawElement[], + appState: AppState, +) => { + const referenceElements = getReferenceElements( + elements, + selectedElements, + appState, + ); + + return getMaximumGroups(referenceElements) + .filter( + (elementsGroup) => + !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), + ) + .flatMap((elementGroup) => getElementsCorners(elementGroup)); +}; + +const getPointSnaps = ( + selectedElements: ExcalidrawElement[], + selectionSnapPoints: Point[], + appState: AppState, + event: KeyboardModifiersObject, + nearestSnapsX: Snaps, + nearestSnapsY: Snaps, + minOffset: Vector2D, +) => { + if ( + !isSnappingEnabled({ appState, event, selectedElements }) || + (selectedElements.length === 0 && selectionSnapPoints.length === 0) + ) { + return []; + } + + const referenceSnapPoints = SnapCache.getReferenceSnapPoints(); + + if (referenceSnapPoints) { + for (const thisSnapPoint of selectionSnapPoints) { + for (const otherSnapPoint of referenceSnapPoints) { + const offsetX = otherSnapPoint[0] - thisSnapPoint[0]; + const offsetY = otherSnapPoint[1] - thisSnapPoint[1]; + + if (Math.abs(offsetX) <= minOffset.x) { + if (Math.abs(offsetX) < minOffset.x) { + nearestSnapsX.length = 0; + } + + nearestSnapsX.push({ + type: "point", + points: [thisSnapPoint, otherSnapPoint], + offset: offsetX, + }); + + minOffset.x = Math.abs(offsetX); + } + + if (Math.abs(offsetY) <= minOffset.y) { + if (Math.abs(offsetY) < minOffset.y) { + nearestSnapsY.length = 0; + } + + nearestSnapsY.push({ + type: "point", + points: [thisSnapPoint, otherSnapPoint], + offset: offsetY, + }); + + minOffset.y = Math.abs(offsetY); + } + } + } + } +}; + +export const snapDraggedElements = ( + selectedElements: ExcalidrawElement[], + dragOffset: Vector2D, + appState: AppState, + event: KeyboardModifiersObject, +) => { + if ( + !isSnappingEnabled({ appState, event, selectedElements }) || + selectedElements.length === 0 + ) { + return { + snapOffset: { + x: 0, + y: 0, + }, + snapLines: [], + }; + } + + dragOffset.x = round(dragOffset.x); + dragOffset.y = round(dragOffset.y); + const nearestSnapsX: Snaps = []; + const nearestSnapsY: Snaps = []; + const snapDistance = getSnapDistance(appState.zoom.value); + const minOffset = { + x: snapDistance, + y: snapDistance, + }; + + const selectionPoints = getElementsCorners(selectedElements, { + dragOffset, + }); + + // get the nearest horizontal and vertical point and gap snaps + getPointSnaps( + selectedElements, + selectionPoints, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + getGapSnaps( + selectedElements, + dragOffset, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + // using the nearest snaps to figure out how + // much the elements need to be offset to be snapped + // to some reference elements + const snapOffset = { + x: nearestSnapsX[0]?.offset ?? 0, + y: nearestSnapsY[0]?.offset ?? 0, + }; + + // once the elements are snapped + // and moved to the snapped position + // we want to use the element's snapped position + // to update nearest snaps so that we can create + // point and gap snap lines correctly without any shifting + + minOffset.x = 0; + minOffset.y = 0; + nearestSnapsX.length = 0; + nearestSnapsY.length = 0; + const newDragOffset = { + x: round(dragOffset.x + snapOffset.x), + y: round(dragOffset.y + snapOffset.y), + }; + + getPointSnaps( + selectedElements, + getElementsCorners(selectedElements, { + dragOffset: newDragOffset, + }), + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + getGapSnaps( + selectedElements, + newDragOffset, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY); + + const gapSnapLines = createGapSnapLines( + selectedElements, + newDragOffset, + [...nearestSnapsX, ...nearestSnapsY].filter( + (snap) => snap.type === "gap", + ) as GapSnap[], + ); + + return { + snapOffset, + snapLines: [...pointSnapLines, ...gapSnapLines], + }; +}; + +const round = (x: number) => { + const decimalPlaces = 6; + return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces; +}; + +const dedupePoints = (points: Point[]): Point[] => { + const map = new Map(); + + for (const point of points) { + const key = point.join(","); + + if (!map.has(key)) { + map.set(key, point); + } + } + + return Array.from(map.values()); +}; + +const createPointSnapLines = ( + nearestSnapsX: Snaps, + nearestSnapsY: Snaps, +): PointSnapLine[] => { + const snapsX = {} as { [key: string]: Point[] }; + const snapsY = {} as { [key: string]: Point[] }; + + if (nearestSnapsX.length > 0) { + for (const snap of nearestSnapsX) { + if (snap.type === "point") { + // key = thisPoint.x + const key = round(snap.points[0][0]); + if (!snapsX[key]) { + snapsX[key] = []; + } + snapsX[key].push( + ...snap.points.map( + (point) => [round(point[0]), round(point[1])] as Point, + ), + ); + } + } + } + + if (nearestSnapsY.length > 0) { + for (const snap of nearestSnapsY) { + if (snap.type === "point") { + // key = thisPoint.y + const key = round(snap.points[0][1]); + if (!snapsY[key]) { + snapsY[key] = []; + } + snapsY[key].push( + ...snap.points.map( + (point) => [round(point[0]), round(point[1])] as Point, + ), + ); + } + } + } + + return Object.entries(snapsX) + .map(([key, points]) => { + return { + type: "points", + points: dedupePoints( + points + .map((point) => { + return [Number(key), point[1]] as Point; + }) + .sort((a, b) => a[1] - b[1]), + ), + } as PointSnapLine; + }) + .concat( + Object.entries(snapsY).map(([key, points]) => { + return { + type: "points", + points: dedupePoints( + points + .map((point) => { + return [point[0], Number(key)] as Point; + }) + .sort((a, b) => a[0] - b[0]), + ), + } as PointSnapLine; + }), + ); +}; + +const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => { + const map = new Map(); + + for (const gapSnapLine of gapSnapLines) { + const key = gapSnapLine.points + .flat() + .map((point) => [round(point)]) + .join(","); + + if (!map.has(key)) { + map.set(key, gapSnapLine); + } + } + + return Array.from(map.values()); +}; + +const createGapSnapLines = ( + selectedElements: ExcalidrawElement[], + dragOffset: Vector2D, + gapSnaps: GapSnap[], +): GapSnapLine[] => { + const [minX, minY, maxX, maxY] = getDraggedElementsBounds( + selectedElements, + dragOffset, + ); + + const gapSnapLines: GapSnapLine[] = []; + + for (const gapSnap of gapSnaps) { + const [startMinX, startMinY, startMaxX, startMaxY] = + gapSnap.gap.startBounds; + const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds; + + const verticalIntersection = rangeIntersection( + [minY, maxY], + gapSnap.gap.overlap, + ); + + const horizontalGapIntersection = rangeIntersection( + [minX, maxX], + gapSnap.gap.overlap, + ); + + switch (gapSnap.direction) { + case "center_horizontal": { + if (verticalIntersection) { + const gapLineY = + (verticalIntersection[0] + verticalIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "horizontal", + points: [ + [gapSnap.gap.startSide[0][0], gapLineY], + [minX, gapLineY], + ], + }, + { + type: "gap", + direction: "horizontal", + points: [ + [maxX, gapLineY], + [gapSnap.gap.endSide[0][0], gapLineY], + ], + }, + ); + } + break; + } + case "center_vertical": { + if (horizontalGapIntersection) { + const gapLineX = + (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, gapSnap.gap.startSide[0][1]], + [gapLineX, minY], + ], + }, + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, maxY], + [gapLineX, gapSnap.gap.endSide[0][1]], + ], + }, + ); + } + break; + } + case "side_right": { + if (verticalIntersection) { + const gapLineY = + (verticalIntersection[0] + verticalIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "horizontal", + points: [ + [startMaxX, gapLineY], + [endMinX, gapLineY], + ], + }, + { + type: "gap", + direction: "horizontal", + points: [ + [endMaxX, gapLineY], + [minX, gapLineY], + ], + }, + ); + } + break; + } + case "side_left": { + if (verticalIntersection) { + const gapLineY = + (verticalIntersection[0] + verticalIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "horizontal", + points: [ + [maxX, gapLineY], + [startMinX, gapLineY], + ], + }, + { + type: "gap", + direction: "horizontal", + points: [ + [startMaxX, gapLineY], + [endMinX, gapLineY], + ], + }, + ); + } + break; + } + case "side_top": { + if (horizontalGapIntersection) { + const gapLineX = + (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, maxY], + [gapLineX, startMinY], + ], + }, + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, startMaxY], + [gapLineX, endMinY], + ], + }, + ); + } + break; + } + case "side_bottom": { + if (horizontalGapIntersection) { + const gapLineX = + (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2; + + gapSnapLines.push( + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, startMaxY], + [gapLineX, endMinY], + ], + }, + { + type: "gap", + direction: "vertical", + points: [ + [gapLineX, endMaxY], + [gapLineX, minY], + ], + }, + ); + } + break; + } + } + } + + return dedupeGapSnapLines( + gapSnapLines.map((gapSnapLine) => { + return { + ...gapSnapLine, + points: gapSnapLine.points.map( + (point) => [round(point[0]), round(point[1])] as Point, + ) as PointPair, + }; + }), + ); +}; + +export const snapResizingElements = ( + // use the latest elements to create snap lines + selectedElements: ExcalidrawElement[], + // while using the original elements to appy dragOffset to calculate snaps + selectedOriginalElements: ExcalidrawElement[], + appState: AppState, + event: KeyboardModifiersObject, + dragOffset: Vector2D, + transformHandle: MaybeTransformHandleType, +) => { + if ( + !isSnappingEnabled({ event, selectedElements, appState }) || + selectedElements.length === 0 || + (selectedElements.length === 1 && + !areRoughlyEqual(selectedElements[0].angle, 0)) + ) { + return { + snapOffset: { x: 0, y: 0 }, + snapLines: [], + }; + } + + let [minX, minY, maxX, maxY] = getCommonBounds(selectedOriginalElements); + + if (transformHandle) { + if (transformHandle.includes("e")) { + maxX += dragOffset.x; + } else if (transformHandle.includes("w")) { + minX += dragOffset.x; + } + + if (transformHandle.includes("n")) { + minY += dragOffset.y; + } else if (transformHandle.includes("s")) { + maxY += dragOffset.y; + } + } + + const selectionSnapPoints: Point[] = []; + + if (transformHandle) { + switch (transformHandle) { + case "e": { + selectionSnapPoints.push([maxX, minY], [maxX, maxY]); + break; + } + case "w": { + selectionSnapPoints.push([minX, minY], [minX, maxY]); + break; + } + case "n": { + selectionSnapPoints.push([minX, minY], [maxX, minY]); + break; + } + case "s": { + selectionSnapPoints.push([minX, maxY], [maxX, maxY]); + break; + } + case "ne": { + selectionSnapPoints.push([maxX, minY]); + break; + } + case "nw": { + selectionSnapPoints.push([minX, minY]); + break; + } + case "se": { + selectionSnapPoints.push([maxX, maxY]); + break; + } + case "sw": { + selectionSnapPoints.push([minX, maxY]); + break; + } + } + } + + const snapDistance = getSnapDistance(appState.zoom.value); + + const minOffset = { + x: snapDistance, + y: snapDistance, + }; + + const nearestSnapsX: Snaps = []; + const nearestSnapsY: Snaps = []; + + getPointSnaps( + selectedOriginalElements, + selectionSnapPoints, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + const snapOffset = { + x: nearestSnapsX[0]?.offset ?? 0, + y: nearestSnapsY[0]?.offset ?? 0, + }; + + // again, once snap offset is calculated + // reset to recompute for creating snap lines to be rendered + minOffset.x = 0; + minOffset.y = 0; + nearestSnapsX.length = 0; + nearestSnapsY.length = 0; + + const [x1, y1, x2, y2] = getCommonBounds(selectedElements).map((bound) => + round(bound), + ); + + const corners: Point[] = [ + [x1, y1], + [x1, y2], + [x2, y1], + [x2, y2], + ]; + + getPointSnaps( + selectedElements, + corners, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY); + + return { + snapOffset, + snapLines: pointSnapLines, + }; +}; + +export const snapNewElement = ( + draggingElement: ExcalidrawElement, + appState: AppState, + event: KeyboardModifiersObject, + origin: Vector2D, + dragOffset: Vector2D, +) => { + if ( + !isSnappingEnabled({ event, selectedElements: [draggingElement], appState }) + ) { + return { + snapOffset: { x: 0, y: 0 }, + snapLines: [], + }; + } + + const selectionSnapPoints: Point[] = [ + [origin.x + dragOffset.x, origin.y + dragOffset.y], + ]; + + const snapDistance = getSnapDistance(appState.zoom.value); + + const minOffset = { + x: snapDistance, + y: snapDistance, + }; + + const nearestSnapsX: Snaps = []; + const nearestSnapsY: Snaps = []; + + getPointSnaps( + [draggingElement], + selectionSnapPoints, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + const snapOffset = { + x: nearestSnapsX[0]?.offset ?? 0, + y: nearestSnapsY[0]?.offset ?? 0, + }; + + minOffset.x = 0; + minOffset.y = 0; + nearestSnapsX.length = 0; + nearestSnapsY.length = 0; + + const corners = getElementsCorners([draggingElement], { + boundingBoxCorners: true, + omitCenter: true, + }); + + getPointSnaps( + [draggingElement], + corners, + appState, + event, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + + const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY); + + return { + snapOffset, + snapLines: pointSnapLines, + }; +}; + +export const getSnapLinesAtPointer = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + pointer: Vector2D, + event: KeyboardModifiersObject, +) => { + if (!isSnappingEnabled({ event, selectedElements: [], appState })) { + return { + originOffset: { x: 0, y: 0 }, + snapLines: [], + }; + } + + const referenceElements = getVisibleAndNonSelectedElements( + elements, + [], + appState, + ); + + const snapDistance = getSnapDistance(appState.zoom.value); + + const minOffset = { + x: snapDistance, + y: snapDistance, + }; + + const horizontalSnapLines: PointerSnapLine[] = []; + const verticalSnapLines: PointerSnapLine[] = []; + + for (const referenceElement of referenceElements) { + const corners = getElementsCorners([referenceElement]); + + for (const corner of corners) { + const offsetX = corner[0] - pointer.x; + + if (Math.abs(offsetX) <= Math.abs(minOffset.x)) { + if (Math.abs(offsetX) < Math.abs(minOffset.x)) { + verticalSnapLines.length = 0; + } + + verticalSnapLines.push({ + type: "pointer", + points: [corner, [corner[0], pointer.y]], + direction: "vertical", + }); + + minOffset.x = offsetX; + } + + const offsetY = corner[1] - pointer.y; + + if (Math.abs(offsetY) <= Math.abs(minOffset.y)) { + if (Math.abs(offsetY) < Math.abs(minOffset.y)) { + horizontalSnapLines.length = 0; + } + + horizontalSnapLines.push({ + type: "pointer", + points: [corner, [pointer.x, corner[1]]], + direction: "horizontal", + }); + + minOffset.y = offsetY; + } + } + } + + return { + originOffset: { + x: + verticalSnapLines.length > 0 + ? verticalSnapLines[0].points[0][0] - pointer.x + : 0, + y: + horizontalSnapLines.length > 0 + ? horizontalSnapLines[0].points[0][1] - pointer.y + : 0, + }, + snapLines: [...verticalSnapLines, ...horizontalSnapLines], + }; +}; + +export const isActiveToolNonLinearSnappable = ( + activeToolType: AppState["activeTool"]["type"], +) => { + return ( + activeToolType === "rectangle" || + activeToolType === "ellipse" || + activeToolType === "diamond" || + activeToolType === "frame" || + activeToolType === "image" + ); +}; diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 488cd624af561..5f9492225586e 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -331,12 +331,17 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "viewMode": true, }, + { + "checked": [Function], + "contextItemLabel": "buttons.objectsSnapMode", + "keyTest": [Function], + "name": "objectsSnapMode", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": true, + }, { "checked": [Function], "contextItemLabel": "buttons.zenMode", @@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 20, "offsetTop": 10, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 98e461a66a208..32dd1754544c8 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -55,12 +55,14 @@ exports[`given element A and group of elements B and given both are selected whe "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -91,6 +93,7 @@ exports[`given element A and group of elements B and given both are selected whe "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -508,12 +511,14 @@ exports[`given element A and group of elements B and given both are selected whe "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -546,6 +551,7 @@ exports[`given element A and group of elements B and given both are selected whe "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -963,12 +969,14 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -992,6 +1000,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -1791,12 +1800,14 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -1822,6 +1833,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2001,12 +2013,14 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2035,6 +2049,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2452,12 +2467,14 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2483,6 +2500,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2691,12 +2709,14 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2720,6 +2740,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -2856,12 +2877,14 @@ exports[`regression tests > can drag element that covers another element, while "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -2887,6 +2910,7 @@ exports[`regression tests > can drag element that covers another element, while "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -3297,12 +3321,14 @@ exports[`regression tests > change the properties of a shape > [end of test] app "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": "elementStroke", "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -3326,6 +3352,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -3591,12 +3618,14 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -3622,6 +3651,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -3833,12 +3863,14 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -3864,6 +3896,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -4086,12 +4119,14 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -4117,6 +4152,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -4325,12 +4361,14 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -4357,6 +4395,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -4666,12 +4705,14 @@ exports[`regression tests > deleting last but one element in editing group shoul "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -4697,6 +4738,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5166,12 +5208,14 @@ exports[`regression tests > deselects group of selected elements on pointer down "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -5224,6 +5268,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5460,12 +5505,14 @@ exports[`regression tests > deselects group of selected elements on pointer up w "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -5490,6 +5537,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5726,12 +5774,14 @@ exports[`regression tests > deselects selected element on pointer down when poin "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -5783,6 +5833,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -5919,12 +5970,14 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -5948,6 +6001,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -6084,12 +6138,14 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -6113,6 +6169,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -6533,12 +6590,14 @@ exports[`regression tests > drags selected elements from point inside common bou "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -6566,6 +6625,7 @@ exports[`regression tests > drags selected elements from point inside common bou "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -6847,12 +6907,14 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -6874,6 +6936,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -8911,12 +8974,14 @@ exports[`regression tests > given a group of selected elements with an element t "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -8943,6 +9008,7 @@ exports[`regression tests > given a group of selected elements with an element t "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -9252,12 +9318,14 @@ exports[`regression tests > given a selected element A and a not selected elemen "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": "elementBackground", "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -9284,6 +9352,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -9492,12 +9561,14 @@ exports[`regression tests > given selected element A with lower z-index than uns "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -9523,6 +9594,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -9688,12 +9760,14 @@ exports[`regression tests > given selected element A with lower z-index than uns "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -9719,6 +9793,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -9956,12 +10031,14 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -9985,6 +10062,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -10121,12 +10199,14 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -10150,6 +10230,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -10286,12 +10367,14 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -10315,6 +10398,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -10451,12 +10535,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -10503,6 +10589,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -10654,12 +10741,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -10706,6 +10795,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -10857,12 +10947,14 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -10884,6 +10976,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11040,12 +11133,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11092,6 +11187,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11243,12 +11339,14 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11272,6 +11370,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11408,12 +11507,14 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11460,6 +11561,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11611,12 +11713,14 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11640,6 +11744,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11776,12 +11881,14 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11803,6 +11910,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -11959,12 +12067,14 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -11988,6 +12098,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -12124,12 +12235,14 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -12161,6 +12274,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -12785,12 +12899,14 @@ exports[`regression tests > noop interaction after undo shouldn't create history "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -12816,6 +12932,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -13024,12 +13141,14 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "lastPointerDownWith": "touch", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -13051,6 +13170,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -13144,12 +13264,14 @@ exports[`regression tests > shift click on selected element should deselect it o "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -13173,6 +13295,7 @@ exports[`regression tests > shift click on selected element should deselect it o "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -13309,12 +13432,14 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -13342,6 +13467,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -13623,12 +13749,14 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -13658,6 +13786,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -14181,12 +14310,14 @@ exports[`regression tests > should show fill icons when element has non transpar "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": "elementBackground", "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -14210,6 +14341,7 @@ exports[`regression tests > should show fill icons when element has non transpar "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -14389,12 +14521,14 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -14428,6 +14562,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -15242,12 +15377,17 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -15269,6 +15409,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -15362,12 +15503,14 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -15393,6 +15536,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -16174,12 +16318,14 @@ exports[`regression tests > switches from group of selected elements to another "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -16234,6 +16380,7 @@ exports[`regression tests > switches from group of selected elements to another "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -16571,12 +16718,14 @@ exports[`regression tests > switches selected element on pointer down > [end of "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -16630,6 +16779,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -16838,12 +16988,14 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "lastPointerDownWith": "touch", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -16865,6 +17017,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -16958,12 +17111,14 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -16987,6 +17142,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -17438,12 +17594,17 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -17465,6 +17626,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", @@ -17558,12 +17720,17 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "lastPointerDownWith": "mouse", "multiElement": null, "name": "Untitled-201933152653", + "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -17585,6 +17752,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 92969faafa117..2d556d3ac95c9 100644 --- a/src/tests/contextmenu.test.tsx +++ b/src/tests/contextmenu.test.tsx @@ -87,6 +87,7 @@ describe("contextMenu element", () => { "gridMode", "zenMode", "viewMode", + "objectsSnapMode", "stats", ]; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 93ee8758f5c2f..a31f6f046f988 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1048,14 +1048,14 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` { "height": 130, - "width": 367, + "width": 366.11716195150507, } `); expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` { - "x": 272, + "x": 271.11716195150507, "y": 45, } `); @@ -1069,9 +1069,9 @@ describe("Test Linear Elements", () => { [ 20, 35, - 502, + 501.11716195150507, 95, - 205.9061448421403, + 205.4589377083102, 52.5, ] `); diff --git a/src/tests/move.test.tsx b/src/tests/move.test.tsx index 5435d7927b500..6e2d60083137f 100644 --- a/src/tests/move.test.tsx +++ b/src/tests/move.test.tsx @@ -84,7 +84,7 @@ describe("move element", () => { // select the second rectangles new Pointer("mouse").clickOn(rectB); - expect(renderInteractiveScene).toHaveBeenCalledTimes(21); + expect(renderInteractiveScene).toHaveBeenCalledTimes(24); expect(renderStaticScene).toHaveBeenCalledTimes(20); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(3); diff --git a/src/tests/multiPointCreate.test.tsx b/src/tests/multiPointCreate.test.tsx index e198b2588938b..bf23955cceccb 100644 --- a/src/tests/multiPointCreate.test.tsx +++ b/src/tests/multiPointCreate.test.tsx @@ -110,7 +110,7 @@ describe("multi point mode in linear elements", () => { key: KEYS.ENTER, }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(h.elements.length).toEqual(1); @@ -153,8 +153,7 @@ describe("multi point mode in linear elements", () => { fireEvent.keyDown(document, { key: KEYS.ENTER, }); - - expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(h.elements.length).toEqual(1); diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index 9c573a1a41cd7..2da2f72d99329 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -55,10 +55,15 @@ exports[`exportToSvg > with default arguments 1`] = ` "lastPointerDownWith": "mouse", "multiElement": null, "name": "name", + "objectsSnapModeEnabled": false, "openDialog": null, "openMenu": null, "openPopup": null, "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, "pasteDialog": { "data": null, "shown": false, @@ -80,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": false, + "snapLines": [], "startBoundElement": null, "suggestedBindings": [], "theme": "light", diff --git a/src/types.ts b/src/types.ts index d87159a70a63f..d4060bb6f0002 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ import Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import { ContextMenuItems } from "./components/ContextMenu"; +import { SnapLine } from "./snapping"; import { Merge, ForwardRef, ValueOf } from "./utility-types"; export type Point = Readonly; @@ -148,6 +149,9 @@ export type InteractiveCanvasAppState = Readonly< showHyperlinkPopup: AppState["showHyperlinkPopup"]; // Collaborators collaborators: AppState["collaborators"]; + // SnapLines + snapLines: AppState["snapLines"]; + zenModeEnabled: AppState["zenModeEnabled"]; } >; @@ -285,6 +289,13 @@ export type AppState = { pendingImageElementId: ExcalidrawImageElement["id"] | null; showHyperlinkPopup: false | "info" | "editor"; selectedLinearElement: LinearElementEditor | null; + + snapLines: SnapLine[]; + originSnapOffset: { + x: number; + y: number; + } | null; + objectsSnapModeEnabled: boolean; }; export type UIAppState = Omit< @@ -406,6 +417,7 @@ export interface ExcalidrawProps { viewModeEnabled?: boolean; zenModeEnabled?: boolean; gridModeEnabled?: boolean; + objectsSnapModeEnabled?: boolean; libraryReturnUrl?: string; theme?: Theme; name?: string; @@ -683,3 +695,10 @@ export type FrameNameBoundsCache = { } >; }; + +export type KeyboardModifiersObject = { + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +};