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: {
...appState, ...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE, gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
}, },
commitToHistory: 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 { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";

View File

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

View File

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

View File

@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null, pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null, 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 }, pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, 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 = < const _clearAppStateForStorage = <

View File

@ -35,6 +35,7 @@ import {
actionLink, actionLink,
actionToggleElementLock, actionToggleElementLock,
actionToggleLinearEditor, actionToggleLinearEditor,
actionToggleObjectsSnapMode,
} from "../actions"; } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
@ -228,6 +229,7 @@ import {
FrameNameBoundsCache, FrameNameBoundsCache,
SidebarName, SidebarName,
SidebarTabName, SidebarTabName,
KeyboardModifiersObject,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -342,6 +344,17 @@ import {
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import {
getSnapLinesAtPointer,
snapDraggedElements,
isActiveToolNonLinearSnappable,
snapNewElement,
snapResizingElements,
isSnappingEnabled,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
} from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper"; import { activeEyeDropperAtom } from "./EyeDropper";
@ -490,6 +503,7 @@ class App extends React.Component<AppProps, AppState> {
viewModeEnabled = false, viewModeEnabled = false,
zenModeEnabled = false, zenModeEnabled = false,
gridModeEnabled = false, gridModeEnabled = false,
objectsSnapModeEnabled = false,
theme = defaultAppState.theme, theme = defaultAppState.theme,
name = defaultAppState.name, name = defaultAppState.name,
} = props; } = props;
@ -500,6 +514,7 @@ class App extends React.Component<AppProps, AppState> {
...this.getCanvasOffsets(), ...this.getCanvasOffsets(),
viewModeEnabled, viewModeEnabled,
zenModeEnabled, zenModeEnabled,
objectsSnapModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null, gridSize: gridModeEnabled ? GRID_SIZE : null,
name, name,
width: window.innerWidth, width: window.innerWidth,
@ -1722,6 +1737,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy(); this.scene.destroy();
this.library.destroy(); this.library.destroy();
ShapeCache.destroy(); ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
isSomeElementSelected.clearCache(); isSomeElementSelected.clearCache();
selectGroupsForSelectedElements.clearCache(); selectGroupsForSelectedElements.clearCache();
@ -3120,15 +3136,21 @@ class App extends React.Component<AppProps, AppState> {
this.onImageAction(); this.onImageAction();
} }
if (nextActiveTool.type !== "selection") { if (nextActiveTool.type !== "selection") {
this.setState({ this.setState((prevState) => ({
activeTool: nextActiveTool, activeTool: nextActiveTool,
selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
snapLines: [],
originSnapOffset: null,
}));
} else {
this.setState({
activeTool: nextActiveTool,
snapLines: [],
originSnapOffset: null,
activeEmbeddable: 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 scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer; 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 ( if (
this.state.editingLinearElement && this.state.editingLinearElement &&
!this.state.editingLinearElement.isDragging !this.state.editingLinearElement.isDragging
@ -4335,6 +4381,10 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null }); this.setState({ contextMenu: null });
} }
if (this.state.snapLines) {
this.setAppState({ snapLines: [] });
}
this.updateGestureOnPointerDown(event); this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs // 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( private onKeyDownFromPointerDownHandler(
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): (event: KeyboardEvent) => void { ): (event: KeyboardEvent) => void {
@ -5845,33 +5941,62 @@ class App extends React.Component<AppProps, AppState> {
!this.state.editingElement && !this.state.editingElement &&
this.state.activeEmbeddable?.state !== "active" this.state.activeEmbeddable?.state !== "active"
) { ) {
const [dragX, dragY] = getGridPoint( const dragOffset = {
pointerCoords.x - pointerDownState.drag.offset.x, x: pointerCoords.x - pointerDownState.origin.x,
pointerCoords.y - pointerDownState.drag.offset.y, y: pointerCoords.y - pointerDownState.origin.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, };
);
const [dragDistanceX, dragDistanceY] = [ const originalElements = [
Math.abs(pointerCoords.x - pointerDownState.origin.x), ...pointerDownState.originalElements.values(),
Math.abs(pointerCoords.y - pointerDownState.origin.y),
]; ];
// We only drag in one direction if shift is pressed // We only drag in one direction if shift is pressed
const lockDirection = event.shiftKey; 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 // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
!this.state.editingFrame && !this.state.editingFrame &&
dragSelectedElements( dragSelectedElements(
pointerDownState, pointerDownState,
selectedElements, selectedElements,
dragX, dragOffset,
dragY,
lockDirection,
dragDistanceX,
dragDistanceY,
this.state, this.state,
this.scene, this.scene,
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
); );
this.maybeSuggestBindingForAll(selectedElements); this.maybeSuggestBindingForAll(selectedElements);
// We duplicate the selected element if alt is pressed on pointer move // We duplicate the selected element if alt is pressed on pointer move
@ -5912,15 +6037,21 @@ class App extends React.Component<AppProps, AppState> {
groupIdMap, groupIdMap,
element, element,
); );
const [originDragX, originDragY] = getGridPoint( const origElement = pointerDownState.originalElements.get(
pointerDownState.origin.x - pointerDownState.drag.offset.x, element.id,
pointerDownState.origin.y - pointerDownState.drag.offset.y, )!;
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
mutateElement(duplicatedElement, { mutateElement(duplicatedElement, {
x: duplicatedElement.x + (originDragX - dragX), x: origElement.x,
y: duplicatedElement.y + (originDragY - dragY), 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); nextElements.push(duplicatedElement);
elementsToAppend.push(element); elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id); oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
@ -5946,6 +6077,8 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
this.scene.replaceAllElements(nextSceneElements); this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
} }
return; return;
} }
@ -6162,6 +6295,7 @@ class App extends React.Component<AppProps, AppState> {
isResizing, isResizing,
isRotating, isRotating,
} = this.state; } = this.state;
this.setState({ this.setState({
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@ -6176,8 +6310,14 @@ class App extends React.Component<AppProps, AppState> {
multiElement || isTextElement(this.state.editingElement) multiElement || isTextElement(this.state.editingElement)
? this.state.editingElement ? this.state.editingElement
: null, : null,
snapLines: [],
originSnapOffset: null,
}); });
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
this.savePointer(childEvent.clientX, childEvent.clientY, "up"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
this.setState({ this.setState({
@ -7705,7 +7845,7 @@ class App extends React.Component<AppProps, AppState> {
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
); );
} else { } else {
const [gridX, gridY] = getGridPoint( let [gridX, gridY] = getGridPoint(
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
@ -7719,6 +7859,33 @@ class App extends React.Component<AppProps, AppState> {
? image.width / image.height ? image.width / image.height
: null; : 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( dragNewElement(
draggingElement, draggingElement,
this.state.activeTool.type, this.state.activeTool.type,
@ -7733,6 +7900,7 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event), : shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
aspectRatio, aspectRatio,
this.state.originSnapOffset,
); );
this.maybeSuggestBindingForAll([draggingElement]); this.maybeSuggestBindingForAll([draggingElement]);
@ -7774,7 +7942,7 @@ class App extends React.Component<AppProps, AppState> {
activeEmbeddable: null, activeEmbeddable: null,
}); });
const pointerCoords = pointerDownState.lastCoords; const pointerCoords = pointerDownState.lastCoords;
const [resizeX, resizeY] = getGridPoint( let [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x, pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y, pointerCoords.y - pointerDownState.resize.offset.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, 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 ( if (
transformElements( transformElements(
pointerDownState, pointerDownState,
@ -7817,6 +8020,7 @@ class App extends React.Component<AppProps, AppState> {
resizeY, resizeY,
pointerDownState.resize.center.x, pointerDownState.resize.center.x,
pointerDownState.resize.center.y, pointerDownState.resize.center.y,
this.state,
) )
) { ) {
this.maybeSuggestBindingForAll(selectedElements); this.maybeSuggestBindingForAll(selectedElements);
@ -7904,6 +8108,7 @@ class App extends React.Component<AppProps, AppState> {
actionUnlockAllElements, actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleGridMode, actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleZenMode, actionToggleZenMode,
actionToggleViewMode, actionToggleViewMode,
actionToggleStats, actionToggleStats,

View File

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

View File

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

View File

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

View File

@ -6,23 +6,22 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks"; import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
pointerX: number, offset: { x: number; y: number },
pointerY: number,
lockDirection: boolean = false,
distanceX: number = 0,
distanceY: number = 0,
appState: AppState, appState: AppState,
scene: Scene, 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 // 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 // but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set // in the frame twice, hence the use of set
@ -44,12 +43,11 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords( updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState, pointerDownState,
element, element,
offset, offset,
snapOffset,
gridSize,
); );
// update coords of bound text only if we're dragging the container directly // update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of) // (we don't drag the group that it's part of)
@ -69,12 +67,11 @@ export const dragSelectedElements = (
(!textElement.frameId || !frames.includes(textElement.frameId)) (!textElement.frameId || !frames.includes(textElement.frameId))
) { ) {
updateElementCoords( updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState, pointerDownState,
textElement, textElement,
offset, offset,
snapOffset,
gridSize,
); );
} }
} }
@ -85,31 +82,40 @@ export const dragSelectedElements = (
}; };
const updateElementCoords = ( const updateElementCoords = (
lockDirection: boolean,
distanceX: number,
distanceY: number,
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
offset: { x: number; y: number }, dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
) => { ) => {
let x: number; const originalElement =
let y: number; pointerDownState.originalElements.get(element.id) ?? element;
if (lockDirection) {
const lockX = lockDirection && distanceX < distanceY; let nextX = originalElement.x + dragOffset.x + snapOffset.x;
const lockY = lockDirection && distanceX > distanceY; let nextY = originalElement.y + dragOffset.y + snapOffset.y;
const original = pointerDownState.originalElements.get(element.id);
x = lockX && original ? original.x : element.x + offset.x; if (snapOffset.x === 0 || snapOffset.y === 0) {
y = lockY && original ? original.y : element.y + offset.y; const [nextGridX, nextGridY] = getGridPoint(
} else { originalElement.x + dragOffset.x,
x = element.x + offset.x; originalElement.y + dragOffset.y,
y = element.y + offset.y; gridSize,
);
if (snapOffset.x === 0) {
nextX = nextGridX;
}
if (snapOffset.y === 0) {
nextY = nextGridY;
}
} }
mutateElement(element, { mutateElement(element, {
x, x: nextX,
y, y: nextY,
}); });
}; };
export const getDragOffsetXY = ( export const getDragOffsetXY = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
x: number, x: number,
@ -133,6 +139,10 @@ export const dragNewElement = (
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */ true */
widthAspectRatio?: number | null, widthAspectRatio?: number | null,
originOffset: {
x: number;
y: number;
} | null = null,
) => { ) => {
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") { if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
if (widthAspectRatio) { if (widthAspectRatio) {
@ -173,8 +183,8 @@ export const dragNewElement = (
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
mutateElement(draggingElement, { mutateElement(draggingElement, {
x: newX, x: newX + (originOffset?.x ?? 0),
y: newY, y: newY + (originOffset?.y ?? 0),
width, width,
height, height,
}); });

View File

@ -41,7 +41,7 @@ import {
MaybeTransformHandleType, MaybeTransformHandleType,
TransformHandleDirection, TransformHandleDirection,
} from "./transformHandles"; } from "./transformHandles";
import { Point, PointerDownState } from "../types"; import { AppState, Point, PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineWidth, getApproxMinLineWidth,
@ -79,6 +79,7 @@ export const transformElements = (
pointerY: number, pointerY: number,
centerX: number, centerX: number,
centerY: number, centerY: number,
appState: AppState,
) => { ) => {
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const [element] = selectedElements; const [element] = selectedElements;
@ -466,8 +467,8 @@ export const resizeSingleElement = (
boundTextElement.fontSize, boundTextElement.fontSize,
boundTextElement.lineHeight, boundTextElement.lineHeight,
); );
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewWidth = Math.max(eleNewWidth, minWidth);
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); eleNewHeight = Math.max(eleNewHeight, minHeight);
} }
} }
@ -508,8 +509,11 @@ export const resizeSingleElement = (
} }
} }
const flipX = eleNewWidth < 0;
const flipY = eleNewHeight < 0;
// Flip horizontally // Flip horizontally
if (eleNewWidth < 0) { if (flipX) {
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
newTopLeft[0] -= Math.abs(newBoundsWidth); newTopLeft[0] -= Math.abs(newBoundsWidth);
} }
@ -517,8 +521,9 @@ export const resizeSingleElement = (
newTopLeft[0] += Math.abs(newBoundsWidth); newTopLeft[0] += Math.abs(newBoundsWidth);
} }
} }
// Flip vertically // Flip vertically
if (eleNewHeight < 0) { if (flipY) {
if (transformHandleDirection.includes("s")) { if (transformHandleDirection.includes("s")) {
newTopLeft[1] -= Math.abs(newBoundsHeight); newTopLeft[1] -= Math.abs(newBoundsHeight);
} }
@ -542,10 +547,20 @@ export const resizeSingleElement = (
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -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 // Readjust points for linear elements
let rescaledElementPointsY; let rescaledElementPointsY;
let rescaledPoints; let rescaledPoints;
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
rescaledElementPointsY = rescalePoints( rescaledElementPointsY = rescalePoints(
1, 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 = { const resizedElement = {
width: Math.abs(eleNewWidth), width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight), height: Math.abs(eleNewHeight),
x: newOrigin[0], x: nextX,
y: newOrigin[1], y: nextY,
points: rescaledPoints, points: rescaledPoints,
}; };
@ -680,6 +690,10 @@ export const resizeMultipleElements = (
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox( const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements), targetElements.map(({ orig }) => orig).concat(boundTextElements),
); );
// const originalHeight = maxY - minY;
// const originalWidth = maxX - minX;
const direction = transformHandleType; const direction = transformHandleType;
const mapDirectionsToAnchors: Record<typeof direction, Point> = { const mapDirectionsToAnchors: Record<typeof direction, Point> = {

View File

@ -957,7 +957,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
85, 85,
4.5, 4.999999999999986,
] ]
`); `);
@ -1002,8 +1002,8 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
375, 374.99999999999994,
-539, -535.0000000000001,
] ]
`); `);
}); });
@ -1190,7 +1190,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); 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); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle); mouse.select(rectangle);
@ -1200,9 +1200,12 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
expect(rectangle.height).toBe(156); expect(rectangle.height).toBeCloseTo(155, 8);
// cache updated again // 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 () => { it("should reset the container height cache when font properties updated", async () => {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { rotate } from "./math"; import { rangeIntersection, rangesOverlap, rotate } from "./math";
describe("rotate", () => { describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { 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]); 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. // angle, which we can check with modulo after rounding.
return Math.round((angle / Math.PI) * 10000) % 5000 === 0; 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, EXTERNAL_LINK_IMG,
getLinkHandleFromCoords, getLinkHandleFromCoords,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameElement,
@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
context.restore(); context.restore();
} }
renderSnaps(context, appState);
// Reset zoom // Reset zoom
context.restore(); 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, getFrameElements,
} from "../frame"; } from "../frame";
import { isShallowEqual } from "../utils"; import { isShallowEqual } from "../utils";
import { isElementInViewport } from "../element/sizeHelpers";
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
@ -89,6 +90,26 @@ export const getElementsWithinSelection = (
return elementsInSelection; 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 // FIXME move this into the editor instance to keep utility methods stateless
export const isSomeElementSelected = (function () { export const isSomeElementSelected = (function () {
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null; 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", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"viewMode": true, "viewMode": true,
}, },
{
"checked": [Function],
"contextItemLabel": "buttons.objectsSnapMode",
"keyTest": [Function],
"name": "objectsSnapMode",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "canvas",
"predicate": [Function],
},
"viewMode": true,
},
{ {
"checked": [Function], "checked": [Function],
"contextItemLabel": "buttons.zenMode", "contextItemLabel": "buttons.zenMode",
@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null,
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "Untitled-201933152653", "name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 20, "offsetLeft": 20,
"offsetTop": 10, "offsetTop": 10,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1048,14 +1048,14 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": 367, "width": 366.11716195150507,
} }
`); `);
expect(getBoundTextElementPosition(container, textElement)) expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"x": 272, "x": 271.11716195150507,
"y": 45, "y": 45,
} }
`); `);
@ -1069,9 +1069,9 @@ describe("Test Linear Elements", () => {
[ [
20, 20,
35, 35,
502, 501.11716195150507,
95, 95,
205.9061448421403, 205.4589377083102,
52.5, 52.5,
] ]
`); `);

View File

@ -84,7 +84,7 @@ describe("move element", () => {
// select the second rectangles // select the second rectangles
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21); expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
expect(renderStaticScene).toHaveBeenCalledTimes(20); expect(renderStaticScene).toHaveBeenCalledTimes(20);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3); expect(h.elements.length).toEqual(3);

View File

@ -110,7 +110,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -153,8 +153,7 @@ describe("multi point mode in linear elements", () => {
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);

View File

@ -55,10 +55,15 @@ exports[`exportToSvg > with default arguments 1`] = `
"lastPointerDownWith": "mouse", "lastPointerDownWith": "mouse",
"multiElement": null, "multiElement": null,
"name": "name", "name": "name",
"objectsSnapModeEnabled": false,
"openDialog": null, "openDialog": null,
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -80,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",

View File

@ -34,6 +34,7 @@ import Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem"; import type { FileSystemHandle } from "./data/filesystem";
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu"; import { ContextMenuItems } from "./components/ContextMenu";
import { SnapLine } from "./snapping";
import { Merge, ForwardRef, ValueOf } from "./utility-types"; import { Merge, ForwardRef, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -150,6 +151,9 @@ export type InteractiveCanvasAppState = Readonly<
showHyperlinkPopup: AppState["showHyperlinkPopup"]; showHyperlinkPopup: AppState["showHyperlinkPopup"];
// Collaborators // Collaborators
collaborators: AppState["collaborators"]; collaborators: AppState["collaborators"];
// SnapLines
snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"];
} }
>; >;
@ -287,6 +291,13 @@ export type AppState = {
pendingImageElementId: ExcalidrawImageElement["id"] | null; pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null; selectedLinearElement: LinearElementEditor | null;
snapLines: SnapLine[];
originSnapOffset: {
x: number;
y: number;
} | null;
objectsSnapModeEnabled: boolean;
}; };
export type UIAppState = Omit< export type UIAppState = Omit<
@ -400,6 +411,7 @@ export interface ExcalidrawProps {
viewModeEnabled?: boolean; viewModeEnabled?: boolean;
zenModeEnabled?: boolean; zenModeEnabled?: boolean;
gridModeEnabled?: boolean; gridModeEnabled?: boolean;
objectsSnapModeEnabled?: boolean;
libraryReturnUrl?: string; libraryReturnUrl?: string;
theme?: Theme; theme?: Theme;
name?: string; name?: string;
@ -653,3 +665,10 @@ export type FrameNameBoundsCache = {
} }
>; >;
}; };
export type KeyboardModifiersObject = {
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
};