grid support (1st iteration) (#1788)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
b6bf011d0d
commit
baa8fb6c14
@ -53,6 +53,7 @@ export const getDefaultAppState = (): AppState => {
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showShortcutsDialog: false,
|
||||
zenModeEnabled: false,
|
||||
gridSize: null,
|
||||
editingGroupId: null,
|
||||
selectedGroupIds: {},
|
||||
};
|
||||
@ -81,5 +82,6 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
|
||||
export const cleanAppStateForExport = (appState: AppState) => {
|
||||
return {
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
gridSize: appState.gridSize,
|
||||
};
|
||||
};
|
||||
|
@ -27,6 +27,9 @@ import {
|
||||
getResizeArrowDirection,
|
||||
getResizeHandlerFromCoords,
|
||||
isNonDeletedElement,
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "../element";
|
||||
import {
|
||||
getElementsWithinSelection,
|
||||
@ -54,7 +57,7 @@ import { renderScene } from "../renderer";
|
||||
import { AppState, GestureEvent, Gesture } from "../types";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
import { distance2d, isPathALoop } from "../math";
|
||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
||||
|
||||
import {
|
||||
isWritableElement,
|
||||
@ -72,6 +75,7 @@ import {
|
||||
isArrowKey,
|
||||
getResizeCenterPointKey,
|
||||
getResizeWithSidesSameLengthKey,
|
||||
getRotateWithDiscreteAngleKey,
|
||||
} from "../keys";
|
||||
|
||||
import { findShapeByKey, shapesShortcutKeys } from "../shapes";
|
||||
@ -109,6 +113,7 @@ import {
|
||||
EVENT,
|
||||
ENV,
|
||||
CANVAS_ONLY_ACTIONS,
|
||||
GRID_SIZE,
|
||||
} from "../constants";
|
||||
import {
|
||||
INITAL_SCENE_UPDATE_TIMEOUT,
|
||||
@ -834,6 +839,12 @@ class App extends React.Component<any, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
toggleGridMode = () => {
|
||||
this.setState({
|
||||
gridSize: this.state.gridSize ? null : GRID_SIZE,
|
||||
});
|
||||
};
|
||||
|
||||
private destroySocketClient = () => {
|
||||
this.setState({
|
||||
isCollaborating: false,
|
||||
@ -1173,6 +1184,10 @@ class App extends React.Component<any, AppState> {
|
||||
this.toggleZenMode();
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
|
||||
this.toggleGridMode();
|
||||
}
|
||||
|
||||
if (event.code === "KeyC" && event.altKey && event.shiftKey) {
|
||||
this.copyToClipboardAsPng();
|
||||
event.preventDefault();
|
||||
@ -1186,9 +1201,12 @@ class App extends React.Component<any, AppState> {
|
||||
const shape = findShapeByKey(event.key);
|
||||
|
||||
if (isArrowKey(event.key)) {
|
||||
const step = event.shiftKey
|
||||
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
|
||||
: ELEMENT_TRANSLATE_AMOUNT;
|
||||
const step =
|
||||
(this.state.gridSize &&
|
||||
(event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
|
||||
(event.shiftKey
|
||||
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
|
||||
: ELEMENT_TRANSLATE_AMOUNT);
|
||||
globalSceneState.replaceAllElements(
|
||||
globalSceneState.getElementsIncludingDeleted().map((el) => {
|
||||
if (this.state.selectedElementIds[el.id]) {
|
||||
@ -2013,6 +2031,11 @@ class App extends React.Component<any, AppState> {
|
||||
|
||||
const originX = x;
|
||||
const originY = y;
|
||||
const [originGridX, originGridY] = getGridPoint(
|
||||
originX,
|
||||
originY,
|
||||
this.state.gridSize,
|
||||
);
|
||||
|
||||
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||
let resizeHandle: ResizeTestType = false;
|
||||
@ -2023,6 +2046,7 @@ class App extends React.Component<any, AppState> {
|
||||
let resizeArrowDirection: "origin" | "end" = "origin";
|
||||
let isResizingElements = false;
|
||||
let draggingOccurred = false;
|
||||
let dragOffsetXY: [number, number] = [0, 0];
|
||||
let hitElement: ExcalidrawElement | null = null;
|
||||
let hitElementWasAddedToSelection = false;
|
||||
|
||||
@ -2106,6 +2130,20 @@ class App extends React.Component<any, AppState> {
|
||||
hitElement ||
|
||||
getElementAtPosition(elements, this.state, x, y, this.state.zoom);
|
||||
|
||||
if (hitElement && isNonDeletedElement(hitElement)) {
|
||||
if (this.state.selectedElementIds[hitElement.id]) {
|
||||
dragOffsetXY = getDragOffsetXY(selectedElements, x, y);
|
||||
} else if (event.shiftKey) {
|
||||
dragOffsetXY = getDragOffsetXY(
|
||||
[...selectedElements, hitElement],
|
||||
x,
|
||||
y,
|
||||
);
|
||||
} else {
|
||||
dragOffsetXY = getDragOffsetXY([hitElement], x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// clear selection if shift is not clicked
|
||||
if (
|
||||
!(hitElement && this.state.selectedElementIds[hitElement.id]) &&
|
||||
@ -2260,10 +2298,15 @@ class App extends React.Component<any, AppState> {
|
||||
});
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
x,
|
||||
y,
|
||||
this.state.elementType === "draw" ? null : this.state.gridSize,
|
||||
);
|
||||
const element = newLinearElement({
|
||||
type: this.state.elementType,
|
||||
x: x,
|
||||
y: y,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
@ -2291,10 +2334,11 @@ class App extends React.Component<any, AppState> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
||||
const element = newElement({
|
||||
type: this.state.elementType,
|
||||
x: x,
|
||||
y: y,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
@ -2356,6 +2400,7 @@ class App extends React.Component<any, AppState> {
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
|
||||
|
||||
// for arrows/lines, don't start dragging until a given threshold
|
||||
// to ensure we don't create a 2-point arrow by mistake when
|
||||
@ -2380,15 +2425,22 @@ class App extends React.Component<any, AppState> {
|
||||
isResizing: resizeHandle && resizeHandle !== "rotation",
|
||||
isRotating: resizeHandle === "rotation",
|
||||
});
|
||||
const [resizeX, resizeY] = getGridPoint(
|
||||
x - resizeOffsetXY[0],
|
||||
y - resizeOffsetXY[1],
|
||||
this.state.gridSize,
|
||||
);
|
||||
if (
|
||||
resizeElements(
|
||||
resizeHandle,
|
||||
setResizeHandle,
|
||||
selectedElements,
|
||||
resizeArrowDirection,
|
||||
event,
|
||||
x - resizeOffsetXY[0],
|
||||
y - resizeOffsetXY[1],
|
||||
getRotateWithDiscreteAngleKey(event),
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
resizeX,
|
||||
resizeY,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@ -2421,21 +2473,12 @@ class App extends React.Component<any, AppState> {
|
||||
this.state,
|
||||
);
|
||||
if (selectedElements.length > 0) {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
x - dragOffsetXY[0],
|
||||
y - dragOffsetXY[1],
|
||||
this.state.gridSize,
|
||||
);
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + x - lastX,
|
||||
y: element.y + y - lastY,
|
||||
});
|
||||
});
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
dragSelectedElements(selectedElements, dragX, dragY);
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
if (event.altKey && !selectedElementWasDuplicated) {
|
||||
@ -2460,9 +2503,14 @@ class App extends React.Component<any, AppState> {
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
const [originDragX, originDragY] = getGridPoint(
|
||||
originX - dragOffsetXY[0],
|
||||
originY - dragOffsetXY[1],
|
||||
this.state.gridSize,
|
||||
);
|
||||
mutateElement(duplicatedElement, {
|
||||
x: duplicatedElement.x + (originX - lastX),
|
||||
y: duplicatedElement.y + (originY - lastY),
|
||||
x: duplicatedElement.x + (originDragX - dragX),
|
||||
y: duplicatedElement.y + (originDragY - dragY),
|
||||
});
|
||||
nextElements.push(duplicatedElement);
|
||||
elementsToAppend.push(element);
|
||||
@ -2486,16 +2534,20 @@ class App extends React.Component<any, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
let width = distance(originX, x);
|
||||
let height = distance(originY, y);
|
||||
|
||||
if (isLinearElement(draggingElement)) {
|
||||
draggingOccurred = true;
|
||||
const points = draggingElement.points;
|
||||
let dx = x - draggingElement.x;
|
||||
let dy = y - draggingElement.y;
|
||||
let dx: number;
|
||||
let dy: number;
|
||||
if (draggingElement.type === "draw") {
|
||||
dx = x - draggingElement.x;
|
||||
dy = y - draggingElement.y;
|
||||
} else {
|
||||
dx = gridX - draggingElement.x;
|
||||
dy = gridY - draggingElement.y;
|
||||
}
|
||||
|
||||
if (event.shiftKey && points.length === 2) {
|
||||
if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
|
||||
({ width: dx, height: dy } = getPerfectElementSize(
|
||||
this.state.elementType,
|
||||
dx,
|
||||
@ -2516,35 +2568,32 @@ class App extends React.Component<any, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (draggingElement.type === "selection") {
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.elementType,
|
||||
originX,
|
||||
originY,
|
||||
x,
|
||||
y,
|
||||
distance(originX, x),
|
||||
distance(originY, y),
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
} else {
|
||||
if (getResizeWithSidesSameLengthKey(event)) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
this.state.elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (getResizeCenterPointKey(event)) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
mutateElement(draggingElement, {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: width,
|
||||
height: height,
|
||||
});
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.elementType,
|
||||
originGridX,
|
||||
originGridY,
|
||||
gridX,
|
||||
gridY,
|
||||
distance(originGridX, gridX),
|
||||
distance(originGridY, gridY),
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.elementType === "selection") {
|
||||
@ -2857,6 +2906,10 @@ class App extends React.Component<any, AppState> {
|
||||
...this.actionManager.getContextMenuItems((action) =>
|
||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||
),
|
||||
{
|
||||
label: t("labels.toggleGridMode"),
|
||||
action: this.toggleGridMode,
|
||||
},
|
||||
],
|
||||
top: event.clientY,
|
||||
left: event.clientX,
|
||||
|
@ -247,6 +247,10 @@ export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("buttons.toggleZenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.toggleGridMode")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
|
@ -68,3 +68,5 @@ export const FONT_FAMILY = {
|
||||
} as const;
|
||||
|
||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
|
72
src/element/dragElements.ts
Normal file
72
src/element/dragElements.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: pointerX + element.x - x1,
|
||||
y: pointerY + element.y - y1,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getDragOffsetXY = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
x: number,
|
||||
y: number,
|
||||
): [number, number] => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
export const dragNewElement = (
|
||||
draggingElement: NonDeletedExcalidrawElement,
|
||||
elementType: typeof SHAPES[number]["value"],
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
isResizeWithSidesSameLength: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
) => {
|
||||
if (isResizeWithSidesSameLength) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (isResizeCenterPoint) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
mutateElement(draggingElement, {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: width,
|
||||
height: height,
|
||||
});
|
||||
}
|
||||
};
|
@ -38,6 +38,11 @@ export {
|
||||
getResizeOffsetXY,
|
||||
getResizeArrowDirection,
|
||||
} from "./resizeElements";
|
||||
export {
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
|
@ -21,10 +21,6 @@ import {
|
||||
getCursorForResizingElement,
|
||||
normalizeResizeHandle,
|
||||
} from "./resizeTest";
|
||||
import {
|
||||
getResizeCenterPointKey,
|
||||
getResizeWithSidesSameLengthKey,
|
||||
} from "../keys";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
|
||||
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||
@ -34,14 +30,21 @@ export const resizeElements = (
|
||||
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
event: PointerEvent, // XXX we want to make it independent?
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
isResizeWithSidesSameLength: boolean,
|
||||
isResizeCenterPoint: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
const [element] = selectedElements;
|
||||
if (resizeHandle === "rotation") {
|
||||
rotateSingleElement(element, pointerX, pointerY, event.shiftKey);
|
||||
rotateSingleElement(
|
||||
element,
|
||||
pointerX,
|
||||
pointerY,
|
||||
isRotateWithDiscreteAngle,
|
||||
);
|
||||
} else if (
|
||||
isLinearElement(element) &&
|
||||
element.points.length === 2 &&
|
||||
@ -53,7 +56,7 @@ export const resizeElements = (
|
||||
resizeSingleTwoPointElement(
|
||||
element,
|
||||
resizeArrowDirection,
|
||||
event.shiftKey,
|
||||
isRotateWithDiscreteAngle,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@ -67,7 +70,7 @@ export const resizeElements = (
|
||||
resizeSingleTextElement(
|
||||
element,
|
||||
resizeHandle,
|
||||
getResizeCenterPointKey(event),
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@ -75,8 +78,8 @@ export const resizeElements = (
|
||||
resizeSingleElement(
|
||||
element,
|
||||
resizeHandle,
|
||||
getResizeWithSidesSameLengthKey(event),
|
||||
getResizeCenterPointKey(event),
|
||||
isResizeWithSidesSameLength,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@ -114,13 +117,13 @@ const rotateSingleElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
isAngleLocking: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (isAngleLocking) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
@ -133,14 +136,14 @@ const rotateSingleElement = (
|
||||
const resizeSingleTwoPointElement = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isAngleLocking: boolean,
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const pointOrigin = element.points[0]; // can assume always [0, 0]?
|
||||
const pointEnd = element.points[1];
|
||||
if (resizeArrowDirection === "end") {
|
||||
if (isAngleLocking) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
const { width, height } = getPerfectElementSize(
|
||||
element.type,
|
||||
pointerX - element.x,
|
||||
@ -162,7 +165,7 @@ const resizeSingleTwoPointElement = (
|
||||
}
|
||||
} else {
|
||||
// resizeArrowDirection === "origin"
|
||||
if (isAngleLocking) {
|
||||
if (isRotateWithDiscreteAngle) {
|
||||
const { width, height } = getPerfectElementSize(
|
||||
element.type,
|
||||
element.x + pointEnd[0] - pointOrigin[0] - pointerX,
|
||||
@ -232,6 +235,16 @@ const measureFontSizeFromWH = (
|
||||
if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) {
|
||||
return { size: nextFontSize, baseline: metrics.baseline };
|
||||
}
|
||||
// third measurement
|
||||
scale *= 0.99; // just heuristics
|
||||
nextFontSize = element.fontSize * scale;
|
||||
metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
);
|
||||
if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) {
|
||||
return { size: nextFontSize, baseline: metrics.baseline };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -16,6 +16,7 @@ export const KEYS = {
|
||||
F_KEY_CODE: 70,
|
||||
ALT_KEY_CODE: 18,
|
||||
Z_KEY_CODE: 90,
|
||||
GRID_KEY_CODE: 222,
|
||||
G_KEY_CODE: 71,
|
||||
} as const;
|
||||
|
||||
@ -32,3 +33,6 @@ export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
|
||||
|
||||
export const getResizeWithSidesSameLengthKey = (event: MouseEvent) =>
|
||||
event.shiftKey;
|
||||
|
||||
export const getRotateWithDiscreteAngleKey = (event: MouseEvent) =>
|
||||
event.shiftKey;
|
||||
|
@ -63,7 +63,8 @@
|
||||
"madeWithExcalidraw": "Made with Excalidraw",
|
||||
"group": "Group selection",
|
||||
"ungroup": "Ungroup selection",
|
||||
"collaborators": "Collaborators"
|
||||
"collaborators": "Collaborators",
|
||||
"toggleGridMode": "Toggle grid mode"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
@ -91,7 +92,8 @@
|
||||
"createNewRoom": "Create new room",
|
||||
"toggleFullScreen": "Toggle full screen",
|
||||
"toggleZenMode": "Toggle zen mode",
|
||||
"exitZenMode": "Exit zen mode"
|
||||
"exitZenMode": "Exit zen mode",
|
||||
"toggleGridMode": "Toggle grid mode"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "This will clear the whole canvas. Are you sure?",
|
||||
|
14
src/math.ts
14
src/math.ts
@ -340,3 +340,17 @@ const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getGridPoint = (
|
||||
x: number,
|
||||
y: number,
|
||||
gridSize: number | null,
|
||||
): [number, number] => {
|
||||
if (gridSize) {
|
||||
return [
|
||||
Math.round(x / gridSize) * gridSize,
|
||||
Math.round(y / gridSize) * gridSize,
|
||||
];
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
|
@ -74,6 +74,29 @@ const strokeCircle = (
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
const renderGrid = (
|
||||
context: CanvasRenderingContext2D,
|
||||
gridSize: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => {
|
||||
const origStrokeStyle = context.strokeStyle;
|
||||
context.strokeStyle = "rgba(0,0,0,0.1)";
|
||||
context.beginPath();
|
||||
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
|
||||
context.moveTo(x, offsetY - gridSize);
|
||||
context.lineTo(x, offsetY + height + gridSize * 2);
|
||||
}
|
||||
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
|
||||
context.moveTo(offsetX - gridSize, y);
|
||||
context.lineTo(offsetX + width + gridSize * 2, y);
|
||||
}
|
||||
context.stroke();
|
||||
context.strokeStyle = origStrokeStyle;
|
||||
};
|
||||
|
||||
const renderLinearPointHandles = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: AppState,
|
||||
@ -167,6 +190,22 @@ export const renderScene = (
|
||||
context.translate(zoomTranslationX, zoomTranslationY);
|
||||
context.scale(sceneState.zoom, sceneState.zoom);
|
||||
|
||||
// Grid
|
||||
if (appState.gridSize) {
|
||||
renderGrid(
|
||||
context,
|
||||
appState.gridSize,
|
||||
-Math.ceil(zoomTranslationX / sceneState.zoom / appState.gridSize) *
|
||||
appState.gridSize +
|
||||
(sceneState.scrollX % appState.gridSize),
|
||||
-Math.ceil(zoomTranslationY / sceneState.zoom / appState.gridSize) *
|
||||
appState.gridSize +
|
||||
(sceneState.scrollY % appState.gridSize),
|
||||
normalizedCanvasWidth / sceneState.zoom,
|
||||
normalizedCanvasHeight / sceneState.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint visible elements
|
||||
const visibleElements = elements.filter((element) =>
|
||||
isVisibleElement(
|
||||
|
@ -24,6 +24,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -421,6 +422,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -627,6 +629,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -750,6 +753,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -1009,6 +1013,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -1170,6 +1175,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -1369,6 +1375,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -1574,6 +1581,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -1880,6 +1888,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -2272,6 +2281,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4057,6 +4067,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4180,6 +4191,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4303,6 +4315,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4426,6 +4439,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4571,6 +4585,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4716,6 +4731,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -4861,6 +4877,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5006,6 +5023,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5129,6 +5147,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5252,6 +5271,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5397,6 +5417,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5520,6 +5541,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -5665,6 +5687,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -6302,6 +6325,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -6508,6 +6532,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -6572,6 +6597,7 @@ Object {
|
||||
"elementType": "rectangle",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -6634,6 +6660,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -7453,6 +7480,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -7849,6 +7877,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -8162,6 +8191,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -8396,6 +8426,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -8555,6 +8586,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -9323,6 +9355,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -9992,6 +10025,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -10566,6 +10600,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -11049,6 +11084,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -11488,6 +11524,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -11842,6 +11879,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -12115,6 +12153,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -12311,6 +12350,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -13130,6 +13170,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -13848,6 +13889,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -14469,6 +14511,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -14997,6 +15040,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -15267,6 +15311,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -15329,6 +15374,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -15452,6 +15498,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -15514,6 +15561,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -16165,6 +16213,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -16229,6 +16278,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -16654,6 +16704,7 @@ Object {
|
||||
"elementType": "text",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
@ -16727,6 +16778,7 @@ Object {
|
||||
"elementType": "selection",
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"gridSize": null,
|
||||
"isCollaborating": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
|
@ -859,10 +859,10 @@ describe("regression tests", () => {
|
||||
fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
const options = contextMenu?.querySelectorAll(".context-menu-option");
|
||||
const expectedOptions = ["Select all"];
|
||||
const expectedOptions = ["Select all", "Toggle grid mode"];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(options?.length).toBe(1);
|
||||
expect(options?.length).toBe(2);
|
||||
expect(options?.item(0).textContent).toBe(expectedOptions[0]);
|
||||
});
|
||||
|
||||
|
@ -72,6 +72,7 @@ export type AppState = {
|
||||
shouldCacheIgnoreZoom: boolean;
|
||||
showShortcutsDialog: boolean;
|
||||
zenModeEnabled: boolean;
|
||||
gridSize: number | null;
|
||||
|
||||
/** top-most selected groups (i.e. does not include nested groups) */
|
||||
selectedGroupIds: { [groupId: string]: boolean };
|
||||
|
Loading…
x
Reference in New Issue
Block a user