Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

This commit is contained in:
Daniel J. Geiger 2023-10-04 18:38:51 -05:00
commit dd4bf91128
34 changed files with 2391 additions and 158 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

@ -60,6 +60,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,
}; };
}; };
@ -208,6 +214,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,
@ -352,6 +354,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";
@ -500,6 +513,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;
@ -510,6 +524,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,
@ -1115,7 +1130,7 @@ class App extends React.Component<AppProps, AppState> {
cursor: CURSOR_TYPE.MOVE, cursor: CURSOR_TYPE.MOVE,
pointerEvents: this.state.viewModeEnabled pointerEvents: this.state.viewModeEnabled
? POINTER_EVENTS.disabled ? POINTER_EVENTS.disabled
: POINTER_EVENTS.inheritFromUI, : POINTER_EVENTS.enabled,
}} }}
onPointerDown={(event) => this.handleCanvasPointerDown(event)} onPointerDown={(event) => this.handleCanvasPointerDown(event)}
onWheel={(event) => this.handleWheel(event)} onWheel={(event) => this.handleWheel(event)}
@ -1751,6 +1766,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();
@ -3150,15 +3166,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 });
} }
}; };
@ -3896,6 +3918,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
@ -4366,6 +4412,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
@ -5650,6 +5700,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 {
@ -5879,33 +5975,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
@ -5946,15 +6071,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);
@ -5980,6 +6111,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;
} }
@ -6196,6 +6329,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,
@ -6210,8 +6344,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({
@ -7739,7 +7879,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,
@ -7753,6 +7893,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,
@ -7767,6 +7934,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]);
@ -7808,7 +7976,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,
@ -7836,6 +8004,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,
@ -7851,6 +8054,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);
@ -7961,6 +8165,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;
@ -462,8 +463,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);
} }
} }
@ -504,8 +505,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);
} }
@ -513,8 +517,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);
} }
@ -538,10 +543,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,
@ -558,16 +573,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,
}; };
@ -676,6 +686,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

@ -177,7 +177,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, frame]); expectEqualIds([rect2, frame]);
}); });
it("should add elements", async () => { it.skip("should add elements", async () => {
h.elements = [rect2, rect3, frame]; h.elements = [rect2, rect3, frame];
func(frame, rect2); func(frame, rect2);
@ -188,7 +188,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect3, rect2, frame]); expectEqualIds([rect3, rect2, frame]);
}); });
it("should add elements when there are other other elements in between", async () => { it.skip("should add elements when there are other other elements in between", async () => {
h.elements = [rect1, rect2, rect4, rect3, frame]; h.elements = [rect1, rect2, rect4, rect3, frame];
func(frame, rect2); func(frame, rect2);
@ -199,7 +199,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect4, rect3, rect2, frame]); expectEqualIds([rect1, rect4, rect3, rect2, frame]);
}); });
it("should add elements when there are other elements in between and the order is reversed", async () => { it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, rect2, rect1, frame]; h.elements = [rect3, rect4, rect2, rect1, frame];
func(frame, rect2); func(frame, rect2);
@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); expectEqualIds([rect1, rect2, rect3, frame, rect4]);
}); });
it("should add elements when there are other elements in between and the order is reversed", async () => { it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, frame, rect2, rect1]; h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2); func(frame, rect2);

View File

@ -14,7 +14,7 @@ import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
} from "./element/textElement"; } from "./element/textElement";
import { arrayToMap, findIndex } from "./utils"; import { arrayToMap } from "./utils";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
@ -457,85 +457,87 @@ export const addElementsToFrame = (
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
const _elementsToAdd: ExcalidrawElement[] = []; const currTargetFrameChildrenMap = new Map(
allElements.reduce(
for (const element of elementsToAdd) { (acc: [ExcalidrawElement["id"], ExcalidrawElement][], element) => {
_elementsToAdd.push(element); if (element.frameId === frame.id) {
acc.push([element.id, element]);
const boundTextElement = getBoundTextElement(element); }
if (boundTextElement) { return acc;
_elementsToAdd.push(boundTextElement); },
} [],
} ),
const allElementsIndex = allElements.reduce(
(acc: Record<string, number>, element, index) => {
acc[element.id] = index;
return acc;
},
{},
); );
const frameIndex = allElementsIndex[frame.id]; const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
// need to be calculated before the mutation below occurs
const leftFrameBoundaryIndex = findIndex(
allElements,
(e) => e.frameId === frame.id,
);
const existingFrameChildren = allElements.filter( const finalElementsToAdd: ExcalidrawElement[] = [];
(element) => element.frameId === frame.id,
);
const addedFrameChildren_left: ExcalidrawElement[] = [];
const addedFrameChildren_right: ExcalidrawElement[] = [];
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrames( for (const element of omitGroupsContainingFrames(
allElements, allElements,
_elementsToAdd, elementsToAdd,
)) { )) {
if (element.frameId !== frame.id && !isFrameElement(element)) { if (!currTargetFrameChildrenMap.has(element.id)) {
if (allElementsIndex[element.id] > frameIndex) { finalElementsToAdd.push(element);
addedFrameChildren_right.push(element); }
} else {
addedFrameChildren_left.push(element);
}
mutateElement( const boundTextElement = getBoundTextElement(element);
element, if (
{ boundTextElement &&
frameId: frame.id, !suppliedElementsToAddSet.has(boundTextElement.id) &&
}, !currTargetFrameChildrenMap.has(boundTextElement.id)
false, ) {
); finalElementsToAdd.push(boundTextElement);
} }
} }
const frameElement = allElements[frameIndex]; const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
const nextFrameChildren = addedFrameChildren_left
.concat(existingFrameChildren)
.concat(addedFrameChildren_right);
const nextFrameChildrenMap = nextFrameChildren.reduce( const nextElements: ExcalidrawElement[] = [];
(acc: Record<string, boolean>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
const nextOtherElements_left = allElements const processedElements = new Set<ExcalidrawElement["id"]>();
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
.filter((element) => !nextFrameChildrenMap[element.id]);
const nextOtherElement_right = allElements for (const element of allElements) {
.slice(frameIndex + 1) if (processedElements.has(element.id)) {
.filter((element) => !nextFrameChildrenMap[element.id]); continue;
}
const nextElements = nextOtherElements_left processedElements.add(element.id);
.concat(nextFrameChildren)
.concat([frameElement]) if (
.concat(nextOtherElement_right); finalElementsToAddSet.has(element.id) ||
(element.frameId && element.frameId === frame.id)
) {
// will be added in bulk once we process target frame
continue;
}
// target frame
if (element.id === frame.id) {
const currFrameChildren = getFrameElements(allElements, frame.id);
currFrameChildren.forEach((child) => {
processedElements.add(child.id);
});
// console.log(currFrameChildren, finalElementsToAdd, element);
nextElements.push(...currFrameChildren, ...finalElementsToAdd, element);
continue;
}
// console.log("(2)", element.frameId);
nextElements.push(element);
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
}
return nextElements; return nextElements;
}; };

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

@ -11,6 +11,22 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## 0.16.1 (2023-09-21)
## Excalidraw Library
**_This section lists the updates made to the excalidraw library and will not affect the integration._**
### Fixes
- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
### Refactor
- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
---
## 0.16.0 (2023-09-19) ## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037). - Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).

View File

@ -1,6 +1,6 @@
{ {
"name": "@excalidraw/excalidraw", "name": "@excalidraw/excalidraw",
"version": "0.16.0", "version": "0.16.1",
"main": "main.js", "main": "main.js",
"types": "types/packages/excalidraw/index.d.ts", "types": "types/packages/excalidraw/index.d.ts",
"files": [ "files": [

View File

@ -22,5 +22,12 @@ const polyfill = () => {
configurable: true, configurable: true,
}); });
} }
if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function (...nodes) {
this.innerHTML = "";
this.append(...nodes);
};
}
}; };
export default polyfill; export default polyfill;

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

@ -41,6 +41,7 @@ import {
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>;
@ -157,6 +158,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"];
} }
>; >;
@ -298,6 +302,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<
@ -411,6 +422,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;
@ -669,3 +681,10 @@ export type FrameNameBoundsCache = {
} }
>; >;
}; };
export type KeyboardModifiersObject = {
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
};