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

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

View file

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

View file

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