feat: element alignments - snapping (#6256)

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
hugofqt 2023-09-28 16:28:08 +02:00 committed by GitHub
parent 4765f5536e
commit 4c35eba72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2295 additions and 87 deletions

View File

@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
};

View File

@ -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,
});

View File

@ -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";

View File

@ -28,6 +28,7 @@ export type ShortcutName =
| "ungroup"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "addToLibrary"
| "viewMode"
@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
objectsSnapMode: [getShortcutKey("Alt+S")],
stats: [getShortcutKey("Alt+/")],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],

View File

@ -51,6 +51,7 @@ export type ActionName =
| "pasteStyles"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"

View File

@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
originSnapOffset: {
x: 0,
y: 0,
},
objectsSnapModeEnabled: false,
};
};
@ -206,6 +212,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 = <

View File

@ -35,6 +35,7 @@ import {
actionLink,
actionToggleElementLock,
actionToggleLinearEditor,
actionToggleObjectsSnapMode,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
@ -228,6 +229,7 @@ import {
FrameNameBoundsCache,
SidebarName,
SidebarTabName,
KeyboardModifiersObject,
} from "../types";
import {
debounce,
@ -342,6 +344,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";
@ -490,6 +503,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
objectsSnapModeEnabled = false,
theme = defaultAppState.theme,
name = defaultAppState.name,
} = props;
@ -500,6 +514,7 @@ class App extends React.Component<AppProps, AppState> {
...this.getCanvasOffsets(),
viewModeEnabled,
zenModeEnabled,
objectsSnapModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
name,
width: window.innerWidth,
@ -1722,6 +1737,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy();
this.library.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
selectGroupsForSelectedElements.clearCache();
@ -3120,15 +3136,21 @@ class App extends React.Component<AppProps, AppState> {
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 });
}
};
@ -3865,6 +3887,30 @@ class App extends React.Component<AppProps, AppState> {
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
@ -4335,6 +4381,10 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null });
}
if (this.state.snapLines) {
this.setAppState({ snapLines: [] });
}
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
@ -5616,6 +5666,52 @@ class App extends React.Component<AppProps, AppState> {
});
};
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 {
@ -5845,33 +5941,62 @@ class App extends React.Component<AppProps, AppState> {
!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
@ -5912,15 +6037,21 @@ class App extends React.Component<AppProps, AppState> {
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);
@ -5946,6 +6077,8 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId,
);
this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
}
return;
}
@ -6162,6 +6295,7 @@ class App extends React.Component<AppProps, AppState> {
isResizing,
isRotating,
} = this.state;
this.setState({
isResizing: false,
isRotating: false,
@ -6176,8 +6310,14 @@ class App extends React.Component<AppProps, AppState> {
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({
@ -7705,7 +7845,7 @@ class App extends React.Component<AppProps, AppState> {
shouldResizeFromCenter(event),
);
} else {
const [gridX, gridY] = getGridPoint(
let [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@ -7719,6 +7859,33 @@ class App extends React.Component<AppProps, AppState> {
? 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,
@ -7733,6 +7900,7 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
aspectRatio,
this.state.originSnapOffset,
);
this.maybeSuggestBindingForAll([draggingElement]);
@ -7774,7 +7942,7 @@ class App extends React.Component<AppProps, AppState> {
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,
@ -7802,6 +7970,41 @@ class App extends React.Component<AppProps, AppState> {
});
});
// 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,
@ -7817,6 +8020,7 @@ class App extends React.Component<AppProps, AppState> {
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
this.state,
)
) {
this.maybeSuggestBindingForAll(selectedElements);
@ -7904,6 +8108,7 @@ class App extends React.Component<AppProps, AppState> {
actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleZenMode,
actionToggleViewMode,
actionToggleStats,

View File

@ -258,6 +258,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("buttons.objectsSnapMode")}
shortcuts={[getShortcutKey("Alt+S")]}
/>
<Shortcut
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}

View File

@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
});
const areEqual = (

View File

@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
];
};
/**
/*
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
@ -674,6 +674,19 @@ export const getCommonBounds = (
return [minX, minY, maxX, maxY];
};
export const getDraggedElementsBounds = (
elements: ExcalidrawElement[],
dragOffset: { x: number; y: number },
) => {
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,

View File

@ -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,
});

View File

@ -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<typeof direction, Point> = {

View File

@ -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 () => {

View File

@ -21,6 +21,7 @@ export const CODES = {
V: "KeyV",
Z: "KeyZ",
R: "KeyR",
S: "KeyS",
} as const;
export const KEYS = {

View File

@ -164,6 +164,7 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"zenMode": "Zen mode",
"objectsSnapMode": "Snap to objects",
"exitZenMode": "Exit zen mode",
"cancel": "Cancel",
"clear": "Clear",

View File

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

View File

@ -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;
};

View File

@ -67,6 +67,7 @@ import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps";
import {
isEmbeddableElement,
isFrameElement,
@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
context.restore();
}
renderSnaps(context, appState);
// Reset zoom
context.restore();

189
src/renderer/renderSnaps.ts Normal file
View File

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

View File

@ -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;

1361
src/snapping.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -87,6 +87,7 @@ describe("contextMenu element", () => {
"gridMode",
"zenMode",
"viewMode",
"objectsSnapMode",
"stats",
];

View File

@ -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,
]
`);

View File

@ -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);

View File

@ -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);

View File

@ -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",

View File

@ -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<RoughPoint>;
@ -150,6 +151,9 @@ export type InteractiveCanvasAppState = Readonly<
showHyperlinkPopup: AppState["showHyperlinkPopup"];
// Collaborators
collaborators: AppState["collaborators"];
// SnapLines
snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"];
}
>;
@ -287,6 +291,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<
@ -400,6 +411,7 @@ export interface ExcalidrawProps {
viewModeEnabled?: boolean;
zenModeEnabled?: boolean;
gridModeEnabled?: boolean;
objectsSnapModeEnabled?: boolean;
libraryReturnUrl?: string;
theme?: Theme;
name?: string;
@ -653,3 +665,10 @@ export type FrameNameBoundsCache = {
}
>;
};
export type KeyboardModifiersObject = {
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
};