feat: improved freedraw (#3512)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Steve Ruiz 2021-05-09 16:42:10 +01:00 committed by GitHub
parent 198800136e
commit 49c6bdd520
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 786 additions and 247 deletions

View File

@ -35,6 +35,7 @@
"nanoid": "3.1.22", "nanoid": "3.1.22",
"open-color": "1.8.0", "open-color": "1.8.0",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "0.4.7",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",

View File

@ -56,14 +56,14 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement const multiPointElement = appState.multiElement
? appState.multiElement ? appState.multiElement
: appState.editingElement?.type === "draw" : appState.editingElement?.type === "freedraw"
? appState.editingElement ? appState.editingElement
: null; : null;
if (multiPointElement) { if (multiPointElement) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
multiPointElement.type !== "draw" && multiPointElement.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { points, lastCommittedPoint } = multiPointElement;
@ -86,7 +86,7 @@ export const actionFinalize = register({
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if ( if (
multiPointElement.type === "line" || multiPointElement.type === "line" ||
multiPointElement.type === "draw" multiPointElement.type === "freedraw"
) { ) {
if (isLoop) { if (isLoop) {
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
@ -118,22 +118,24 @@ export const actionFinalize = register({
); );
} }
if (!appState.elementLocked && appState.elementType !== "draw") { if (!appState.elementLocked && appState.elementType !== "freedraw") {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (
(!appState.elementLocked && appState.elementType !== "draw") || (!appState.elementLocked && appState.elementType !== "freedraw") ||
!multiPointElement !multiPointElement
) { ) {
resetCursor(canvas); resetCursor(canvas);
} }
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
elementType: elementType:
(appState.elementLocked || appState.elementType === "draw") && (appState.elementLocked || appState.elementType === "freedraw") &&
multiPointElement multiPointElement
? appState.elementType ? appState.elementType
: "selection", : "selection",
@ -145,14 +147,14 @@ export const actionFinalize = register({
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement &&
!appState.elementLocked && !appState.elementLocked &&
appState.elementType !== "draw" appState.elementType !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
}, },
commitToHistory: appState.elementType === "draw", commitToHistory: appState.elementType === "freedraw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>

View File

@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types"; import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { isLinearElement } from "../element/typeChecks"; import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding"; import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -114,7 +114,7 @@ const flipElement = (
const originalAngle = normalizeAngle(element.angle); const originalAngle = normalizeAngle(element.angle);
let finalOffsetX = 0; let finalOffsetX = 0;
if (isLinearElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
finalOffsetX = finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width; element.width;

View File

@ -52,6 +52,7 @@ export type ActionName =
| "changeBackgroundColor" | "changeBackgroundColor"
| "changeFillStyle" | "changeFillStyle"
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"

View File

@ -9,7 +9,8 @@ import {
canHaveArrowheads, canHaveArrowheads,
getTargetElements, getTargetElements,
hasBackground, hasBackground,
hasStroke, hasStrokeStyle,
hasStrokeWidth,
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@ -53,10 +54,17 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStroke(elementType) || {(hasStrokeWidth(elementType) ||
targetElements.some((element) => hasStroke(element.type))) && ( targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")} {renderAction("changeSloppiness")}
</> </>

View File

@ -1,4 +1,3 @@
import { Point, simplify } from "points-on-curve";
import React, { useContext } from "react"; import React, { useContext } from "react";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
@ -70,7 +69,7 @@ import {
import { loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json"; import { isValidLibrary } from "../data/json";
import Library from "../data/library"; import Library from "../data/library";
import { restore } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
import { import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
@ -111,7 +110,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import { MaybeTransformHandleType } from "../element/transformHandles"; import { MaybeTransformHandleType } from "../element/transformHandles";
import { import {
isBindingElement, isBindingElement,
@ -122,6 +121,7 @@ import {
import { import {
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@ -1266,7 +1266,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} else if (data.elements) { } else if (data.elements) {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements: data.elements, elements: restoreElements(data.elements),
position: "cursor", position: "cursor",
}); });
} else if (data.text) { } else if (data.text) {
@ -2341,7 +2341,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} else if ( } else if (
this.state.elementType === "arrow" || this.state.elementType === "arrow" ||
this.state.elementType === "draw" ||
this.state.elementType === "line" this.state.elementType === "line"
) { ) {
this.handleLinearElementOnPointerDown( this.handleLinearElementOnPointerDown(
@ -2349,6 +2348,12 @@ class App extends React.Component<AppProps, AppState> {
this.state.elementType, this.state.elementType,
pointerDownState, pointerDownState,
); );
} else if (this.state.elementType === "freedraw") {
this.handleFreeDrawElementOnPointerDown(
event,
this.state.elementType,
pointerDownState,
);
} else { } else {
this.createGenericElementOnPointerDown( this.createGenericElementOnPointerDown(
this.state.elementType, this.state.elementType,
@ -2845,6 +2850,65 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private handleFreeDrawElementOnPointerDown = (
event: React.PointerEvent<HTMLCanvasElement>,
elementType: ExcalidrawFreeDrawElement["type"],
pointerDownState: PointerDownState,
) => {
// Begin a mark capture. This does not have to update state yet.
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
null,
);
const element = newFreeDrawElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemLinearStrokeSharpness,
simulatePressure: event.pressure === 0.5,
});
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[element.id]: false,
},
}));
const pressures = element.simulatePressure
? element.pressures
: [...element.pressures, event.pressure];
mutateElement(element, {
points: [[0, 0]],
pressures,
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene,
);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
this.setState({
draggingElement: element,
editingElement: element,
startBoundElement: boundElement,
suggestedBindings: [],
});
};
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
elementType: ExcalidrawLinearElement["type"], elementType: ExcalidrawLinearElement["type"],
@ -2899,7 +2963,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
elementType === "draw" ? null : this.state.gridSize, this.state.gridSize,
); );
/* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads. /* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
@ -3107,6 +3171,7 @@ class App extends React.Component<AppProps, AppState> {
const hasHitASelectedElement = pointerDownState.hit.allHitElements.some( const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
(element) => this.isASelectedElement(element), (element) => this.isASelectedElement(element),
); );
if ( if (
hasHitASelectedElement || hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
@ -3207,18 +3272,24 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (isLinearElement(draggingElement)) { if (draggingElement.type === "freedraw") {
const points = draggingElement.points;
const dx = pointerCoords.x - draggingElement.x;
const dy = pointerCoords.y - draggingElement.y;
const pressures = draggingElement.simulatePressure
? draggingElement.pressures
: [...draggingElement.pressures, event.pressure];
mutateElement(draggingElement, {
points: [...points, [dx, dy]],
pressures,
});
} else if (isLinearElement(draggingElement)) {
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
const points = draggingElement.points; const points = draggingElement.points;
let dx: number; let dx = gridX - draggingElement.x;
let dy: number; let dy = gridY - draggingElement.y;
if (draggingElement.type === "draw") {
dx = pointerCoords.x - draggingElement.x;
dy = pointerCoords.y - draggingElement.y;
} else {
dx = gridX - draggingElement.x;
dy = gridY - draggingElement.y;
}
if (getRotateWithDiscreteAngleKey(event) && points.length === 2) { if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
({ width: dx, height: dy } = getPerfectElementSize( ({ width: dx, height: dy } = getPerfectElementSize(
@ -3231,19 +3302,11 @@ class App extends React.Component<AppProps, AppState> {
if (points.length === 1) { if (points.length === 1) {
mutateElement(draggingElement, { points: [...points, [dx, dy]] }); mutateElement(draggingElement, { points: [...points, [dx, dy]] });
} else if (points.length > 1) { } else if (points.length > 1) {
if (draggingElement.type === "draw") { mutateElement(draggingElement, {
mutateElement(draggingElement, { points: [...points.slice(0, -1), [dx, dy]],
points: simplify( });
[...(points as Point[]), [dx, dy]],
0.7 / this.state.zoom.value,
),
});
} else {
mutateElement(draggingElement, {
points: [...points.slice(0, -1), [dx, dy]],
});
}
} }
if (isBindingElement(draggingElement)) { if (isBindingElement(draggingElement)) {
// When creating a linear element by dragging // When creating a linear element by dragging
this.maybeSuggestBindingForLinearElementAtCursor( this.maybeSuggestBindingForLinearElementAtCursor(
@ -3383,8 +3446,33 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.eventListeners.onKeyUp!, pointerDownState.eventListeners.onKeyUp!,
); );
if (draggingElement?.type === "draw") { if (draggingElement?.type === "freedraw") {
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
this.state,
);
const points = draggingElement.points;
let dx = pointerCoords.x - draggingElement.x;
let dy = pointerCoords.y - draggingElement.y;
// Allows dots to avoid being flagged as infinitely small
if (dx === points[0][0] && dy === points[0][1]) {
dy += 0.0001;
dx += 0.0001;
}
const pressures = draggingElement.simulatePressure
? []
: [...draggingElement.pressures, childEvent.pressure];
mutateElement(draggingElement, {
points: [...points, [dx, dy]],
pressures,
});
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
return; return;
} }
@ -3428,7 +3516,7 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
if (!elementLocked && elementType !== "draw") { if (!elementLocked) {
resetCursor(this.canvas); resetCursor(this.canvas);
this.setState((prevState) => ({ this.setState((prevState) => ({
draggingElement: null, draggingElement: null,
@ -3575,7 +3663,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (!elementLocked && elementType !== "draw" && draggingElement) { if (!elementLocked && elementType !== "freedraw" && draggingElement) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: { selectedElementIds: {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
@ -3599,7 +3687,7 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
if (!elementLocked && elementType !== "draw") { if (!elementLocked && elementType !== "freedraw") {
resetCursor(this.canvas); resetCursor(this.canvas);
this.setState({ this.setState({
draggingElement: null, draggingElement: null,

View File

@ -153,7 +153,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut <Shortcut
label={t("toolBar.draw")} label={t("toolBar.freedraw")}
shortcuts={["Shift+P", "7"]} shortcuts={["Shift+P", "7"]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />

View File

@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti"); return t("hints.linearElementMulti");
} }
if (elementType === "draw") { if (elementType === "freedraw") {
return t("hints.freeDraw"); return t("hints.freeDraw");
} }

View File

@ -37,10 +37,12 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
const restoreElementWithProperties = <T extends ExcalidrawElement>( const restoreElementWithProperties = <T extends ExcalidrawElement>(
element: Required<T>, element: Required<T>,
extra: Omit<Required<T>, keyof ExcalidrawElement>, extra: Omit<Required<T>, keyof ExcalidrawElement> & {
type?: ExcalidrawElement["type"];
},
): T => { ): T => {
const base: Pick<T, keyof ExcalidrawElement> = { const base: Pick<T, keyof ExcalidrawElement> = {
type: element.type, type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up // all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements // newly added elements
version: element.version || 1, version: element.version || 1,
@ -97,6 +99,14 @@ const restoreElement = (
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
}); });
case "freedraw": {
return restoreElementWithProperties(element, {
points: element.points,
lastCommittedPoint: null,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
});
}
case "draw": case "draw":
case "line": case "line":
case "arrow": { case "arrow": {
@ -106,6 +116,7 @@ const restoreElement = (
} = element; } = element;
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
type: element.type === "draw" ? "line" : element.type,
startBinding: element.startBinding, startBinding: element.startBinding,
endBinding: element.endBinding, endBinding: element.endBinding,
points: points:

View File

@ -1,4 +1,9 @@
import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types"; import {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
} from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core"; import { Drawable, Op } from "roughjs/bin/core";
@ -7,7 +12,7 @@ import {
getShapeForElement, getShapeForElement,
generateRoughOptions, generateRoughOptions,
} from "../renderer/renderElement"; } from "../renderer/renderElement";
import { isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
@ -18,7 +23,9 @@ export type Bounds = readonly [number, number, number, number];
export const getElementAbsoluteCoords = ( export const getElementAbsoluteCoords = (
element: ExcalidrawElement, element: ExcalidrawElement,
): Bounds => { ): Bounds => {
if (isLinearElement(element)) { if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(element); return getLinearElementAbsoluteCoords(element);
} }
return [ return [
@ -120,9 +127,42 @@ const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
): [number, number, number, number] => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
};
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
const getLinearElementAbsoluteCoords = ( const getLinearElementAbsoluteCoords = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
): [number, number, number, number] => { ): [number, number, number, number] => {
let coords: [number, number, number, number];
if (element.points.length < 2 || !getShapeForElement(element)) { if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful // XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce( const { minX, minY, maxX, maxY } = element.points.reduce(
@ -137,7 +177,21 @@ const getLinearElementAbsoluteCoords = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return [ coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else {
const shape = getShapeForElement(element) as Drawable[];
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
coords = [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,
maxX + element.x, maxX + element.x,
@ -145,19 +199,7 @@ const getLinearElementAbsoluteCoords = (
]; ];
} }
const shape = getShapeForElement(element) as Drawable[]; return coords;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
}; };
export const getArrowheadPoints = ( export const getArrowheadPoints = (
@ -231,7 +273,7 @@ export const getArrowheadPoints = (
const ys = y2 - ny * minSize; const ys = y2 - ny * minSize;
if (arrowhead === "dot") { if (arrowhead === "dot") {
const r = Math.hypot(ys - y2, xs - x2); const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
return [x2, y2, r]; return [x2, y2, r];
} }
@ -277,16 +319,31 @@ const getLinearElementRotatedBounds = (
return getMinMaxXYFromCurvePathOps(ops, transformXY); return getMinMaxXYFromCurvePathOps(ops, transformXY);
}; };
// We could cache this stuff
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
): [number, number, number, number] => { ): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
if (isLinearElement(element)) { if (isFreeDrawElement(element)) {
return getLinearElementRotatedBounds(element, cx, cy); const [minX, minY, maxX, maxY] = getBoundsFromPoints(
} element.points.map(([x, y]) =>
if (element.type === "diamond") { rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle); const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle); const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle); const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
@ -295,26 +352,28 @@ export const getElementBounds = (
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21); const maxY = Math.max(y11, y12, y22, y21);
return [minX, minY, maxX, maxY]; bounds = [minX, minY, maxX, maxY];
} } else if (element.type === "ellipse") {
if (element.type === "ellipse") {
const w = (x2 - x1) / 2; const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2; const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle); const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle); const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin); const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin); const hh = Math.hypot(h * cos, w * sin);
return [cx - ww, cy - hh, cx + ww, cy + hh]; bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} }
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle); return bounds;
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
return [minX, minY, maxX, maxY];
}; };
export const getCommonBounds = ( export const getCommonBounds = (
@ -345,7 +404,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
): [number, number, number, number] => { ): [number, number, number, number] => {
if (!isLinearElement(element)) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return [
element.x, element.x,
element.y, element.y,
@ -360,16 +419,29 @@ export const getResizedElementAbsoluteCoords = (
rescalePoints(1, nextHeight, element.points), rescalePoints(1, nextHeight, element.points),
); );
const gen = rough.generator(); let bounds: [number, number, number, number];
const curve =
element.strokeSharpness === "sharp" if (isFreeDrawElement(element)) {
? gen.linearPath( // Free Draw
points as [number, number][], bounds = getBoundsFromPoints(points);
generateRoughOptions(element), } else {
) // Line
: gen.curve(points as [number, number][], generateRoughOptions(element)); const gen = rough.generator();
const ops = getCurvePathOps(curve); const curve =
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
}
const [minX, minY, maxX, maxY] = bounds;
return [ return [
minX + element.x, minX + element.x,
minY + element.y, minY + element.y,

View File

@ -4,7 +4,13 @@ import * as GADirection from "../gadirections";
import * as GALine from "../galines"; import * as GALine from "../galines";
import * as GATransform from "../gatransforms"; import * as GATransform from "../gatransforms";
import { isPathALoop, isPointInPolygon, rotate } from "../math"; import {
distance2d,
rotatePoint,
isPathALoop,
isPointInPolygon,
rotate,
} from "../math";
import { pointsOnBezierCurves } from "points-on-curve"; import { pointsOnBezierCurves } from "points-on-curve";
import { import {
@ -16,6 +22,7 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
NonDeleted, NonDeleted,
ExcalidrawFreeDrawElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -30,10 +37,17 @@ const isElementDraggableFromInside = (
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
} }
if (element.type === "freedraw") {
return true;
}
const isDraggableFromInside = element.backgroundColor !== "transparent"; const isDraggableFromInside = element.backgroundColor !== "transparent";
if (element.type === "line" || element.type === "draw") {
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points); return isDraggableFromInside && isPathALoop(element.points);
} }
return isDraggableFromInside; return isDraggableFromInside;
}; };
@ -81,6 +95,7 @@ const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({ element, point, threshold, check });
}; };
@ -151,6 +166,18 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "ellipse": case "ellipse":
const distance = distanceToBindableElement(args.element, args.point); const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold); return args.check(distance, args.threshold);
case "freedraw": {
if (
!args.check(
distanceToRectangle(args.element, args.point),
args.threshold,
)
) {
return false;
}
return hitTestFreeDrawElement(args.element, args.point, args.threshold);
}
case "arrow": case "arrow":
case "line": case "line":
case "draw": case "draw":
@ -195,7 +222,10 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => {
}; };
const distanceToRectangle = ( const distanceToRectangle = (
element: ExcalidrawRectangleElement | ExcalidrawTextElement, element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -267,6 +297,71 @@ const ellipseParamsForTest = (
return [pointRel, tangent]; return [pointRel, tangent];
}; };
const hitTestFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
point: Point,
threshold: number,
): boolean => {
// Check point-distance-to-line-segment for every segment in the
// element's points (its input points, not its outline points).
// This is... okay? It's plenty fast, but the GA library may
// have a faster option.
let x: number;
let y: number;
if (element.angle === 0) {
x = point[0] - element.x;
y = point[1] - element.y;
} else {
// Counter-rotate the point around center before testing
const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element);
const rotatedPoint = rotatePoint(
point,
[minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
-element.angle,
);
x = rotatedPoint[0] - element.x;
y = rotatedPoint[1] - element.y;
}
let [A, B] = element.points;
let P: readonly [number, number];
// For freedraw dots
if (element.points.length === 2) {
return (
distance2d(A[0], A[1], x, y) < threshold ||
distance2d(B[0], B[1], x, y) < threshold
);
}
// For freedraw lines
for (let i = 1; i < element.points.length - 1; i++) {
const delta = [B[0] - A[0], B[1] - A[1]];
const length = Math.hypot(delta[1], delta[0]);
const U = [delta[0] / length, delta[1] / length];
const C = [x - A[0], y - A[1]];
const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
P = [A[0] + U[0] * d, A[1] + U[1] * d];
const da = distance2d(P[0], P[1], A[0], A[1]);
const db = distance2d(P[0], P[1], B[0], B[1]);
P = db < da && da > length ? B : da < db && db > length ? A : P;
if (Math.hypot(y - P[1], x - P[0]) < threshold) {
return true;
}
A = B;
B = element.points[i + 1];
}
return false;
};
const hitTestLinear = (args: HitTestArgs): boolean => { const hitTestLinear = (args: HitTestArgs): boolean => {
const { element, threshold } = args; const { element, threshold } = args;
if (!getShapeForElement(element)) { if (!getShapeForElement(element)) {

View File

@ -9,6 +9,7 @@ import {
GroupId, GroupId,
VerticalAlign, VerticalAlign,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
} from "../element/types"; } from "../element/types";
import { measureText, getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
@ -212,6 +213,22 @@ export const updateTextElement = (
}); });
}; };
export const newFreeDrawElement = (
opts: {
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
pressures: [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
};
};
export const newLinearElement = ( export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];

View File

@ -18,7 +18,11 @@ import {
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { isLinearElement, isTextElement } from "./typeChecks"; import {
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { measureText, getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
@ -244,7 +248,7 @@ const rescalePointsInElement = (
width: number, width: number,
height: number, height: number,
) => ) =>
isLinearElement(element) isLinearElement(element) || isFreeDrawElement(element)
? { ? {
points: rescalePoints( points: rescalePoints(
0, 0,
@ -404,7 +408,7 @@ export const resizeSingleElement = (
-stateAtResizeStart.angle, -stateAtResizeStart.angle,
); );
//Get bounds corners rendered on screen // Get bounds corners rendered on screen
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords( const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
element, element,
element.width, element.width,
@ -644,11 +648,14 @@ const resizeMultipleElements = (
font = { fontSize: nextFont.size, baseline: nextFont.baseline }; font = { fontSize: nextFont.size, baseline: nextFont.baseline };
} }
const origCoords = getElementAbsoluteCoords(element); const origCoords = getElementAbsoluteCoords(element);
const rescaledPoints = rescalePointsInElement(element, width, height); const rescaledPoints = rescalePointsInElement(element, width, height);
updateBoundElements(element, { updateBoundElements(element, {
newSize: { width, height }, newSize: { width, height },
simultaneouslyUpdated: elements, simultaneouslyUpdated: elements,
}); });
const finalCoords = getResizedElementAbsoluteCoords( const finalCoords = getResizedElementAbsoluteCoords(
{ {
...element, ...element,
@ -657,6 +664,7 @@ const resizeMultipleElements = (
width, width,
height, height,
); );
const { x, y } = getNextXY(element, origCoords, finalCoords); const { x, y } = getNextXY(element, origCoords, finalCoords);
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }]; return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
}, },

View File

@ -1,12 +1,12 @@
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
export const isInvisiblySmallElement = ( export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
): boolean => { ): boolean => {
if (isLinearElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2; return element.points.length < 2;
} }
return element.width === 0 && element.height === 0; return element.width === 0 && element.height === 0;
@ -26,7 +26,7 @@ export const getPerfectElementSize = (
if ( if (
elementType === "line" || elementType === "line" ||
elementType === "arrow" || elementType === "arrow" ||
elementType === "draw" elementType === "freedraw"
) { ) {
const lockedAngle = const lockedAngle =
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) * Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *

View File

@ -225,7 +225,7 @@ export const getTransformHandles = (
if ( if (
element.type === "arrow" || element.type === "arrow" ||
element.type === "line" || element.type === "line" ||
element.type === "draw" element.type === "freedraw"
) { ) {
if (element.points.length === 2) { if (element.points.length === 2) {
// only check the last point because starting point is always (0,0) // only check the last point because starting point is always (0,0)

View File

@ -4,6 +4,7 @@ import {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
} from "./types"; } from "./types";
export const isGenericElement = ( export const isGenericElement = (
@ -24,6 +25,18 @@ export const isTextElement = (
return element != null && element.type === "text"; return element != null && element.type === "text";
}; };
export const isFreeDrawElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => {
return element != null && isFreeDrawElementType(element.type);
};
export const isFreeDrawElementType = (
elementType: ExcalidrawElement["type"],
): boolean => {
return elementType === "freedraw";
};
export const isLinearElement = ( export const isLinearElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => { ): element is ExcalidrawLinearElement => {
@ -34,7 +47,7 @@ export const isLinearElementType = (
elementType: ExcalidrawElement["type"], elementType: ExcalidrawElement["type"],
): boolean => { ): boolean => {
return ( return (
elementType === "arrow" || elementType === "line" || elementType === "draw" elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
); );
}; };
@ -69,7 +82,7 @@ export const isExcalidrawElement = (element: any): boolean => {
element?.type === "rectangle" || element?.type === "rectangle" ||
element?.type === "ellipse" || element?.type === "ellipse" ||
element?.type === "arrow" || element?.type === "arrow" ||
element?.type === "draw" || element?.type === "freedraw" ||
element?.type === "line" element?.type === "line"
); );
}; };

View File

@ -78,7 +78,8 @@ export type ExcalidrawGenericElement =
export type ExcalidrawElement = export type ExcalidrawElement =
| ExcalidrawGenericElement | ExcalidrawGenericElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement; | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false; isDeleted: false;
@ -121,3 +122,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly Point[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: Point | null;
}>;

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "تحديد", "selection": "تحديد",
"draw": "الكتابة الحرة", "freedraw": "الكتابة الحرة",
"rectangle": "مستطيل", "rectangle": "مستطيل",
"diamond": "مضلع", "diamond": "مضلع",
"ellipse": "دائرة", "ellipse": "دائرة",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Селекция", "selection": "Селекция",
"draw": "Рисуване", "freedraw": "Рисуване",
"rectangle": "Правоъгълник", "rectangle": "Правоъгълник",
"diamond": "Диамант", "diamond": "Диамант",
"ellipse": "Елипс", "ellipse": "Елипс",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selecció", "selection": "Selecció",
"draw": "Dibuix lliure", "freedraw": "Dibuix lliure",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Rombe", "diamond": "Rombe",
"ellipse": "El·lipse", "ellipse": "El·lipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Auswahl", "selection": "Auswahl",
"draw": "Freies Zeichnen", "freedraw": "Freies Zeichnen",
"rectangle": "Rechteck", "rectangle": "Rechteck",
"diamond": "Raute", "diamond": "Raute",
"ellipse": "Ellipse", "ellipse": "Ellipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Επιλογή", "selection": "Επιλογή",
"draw": "Ελεύθερο σχέδιο", "freedraw": "Ελεύθερο σχέδιο",
"rectangle": "Ορθογώνιο", "rectangle": "Ορθογώνιο",
"diamond": "Ρόμβος", "diamond": "Ρόμβος",
"ellipse": "Έλλειψη", "ellipse": "Έλλειψη",

View File

@ -20,6 +20,10 @@
"background": "Background", "background": "Background",
"fill": "Fill", "fill": "Fill",
"strokeWidth": "Stroke width", "strokeWidth": "Stroke width",
"strokeShape": "Stroke shape",
"strokeShape_gel": "Gel pen",
"strokeShape_fountain": "Fountain pen",
"strokeShape_brush": "Brush pen",
"strokeStyle": "Stroke style", "strokeStyle": "Stroke style",
"strokeStyle_solid": "Solid", "strokeStyle_solid": "Solid",
"strokeStyle_dashed": "Dashed", "strokeStyle_dashed": "Dashed",
@ -153,7 +157,6 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selection", "selection": "Selection",
"draw": "Free draw",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Diamond", "diamond": "Diamond",
"ellipse": "Ellipse", "ellipse": "Ellipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selección", "selection": "Selección",
"draw": "Dibujo libre", "freedraw": "Dibujo libre",
"rectangle": "Rectángulo", "rectangle": "Rectángulo",
"diamond": "Diamante", "diamond": "Diamante",
"ellipse": "Elipse", "ellipse": "Elipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "گزینش", "selection": "گزینش",
"draw": "طراحی آزاد", "freedraw": "طراحی آزاد",
"rectangle": "مستطیل", "rectangle": "مستطیل",
"diamond": "لوزی", "diamond": "لوزی",
"ellipse": "بیضی", "ellipse": "بیضی",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Valinta", "selection": "Valinta",
"draw": "Vapaa piirto", "freedraw": "Vapaa piirto",
"rectangle": "Suorakulmio", "rectangle": "Suorakulmio",
"diamond": "Vinoneliö", "diamond": "Vinoneliö",
"ellipse": "Soikio", "ellipse": "Soikio",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Sélection", "selection": "Sélection",
"draw": "Dessin libre", "freedraw": "Dessin libre",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Losange", "diamond": "Losange",
"ellipse": "Ellipse", "ellipse": "Ellipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "בחירה", "selection": "בחירה",
"draw": "ציור חופשי", "freedraw": "ציור חופשי",
"rectangle": "מרובע", "rectangle": "מרובע",
"diamond": "מעוין", "diamond": "מעוין",
"ellipse": "אליפסה", "ellipse": "אליפסה",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "चयन", "selection": "चयन",
"draw": "मुफ्त ड्रा", "freedraw": "मुफ्त ड्रा",
"rectangle": "आयात", "rectangle": "आयात",
"diamond": "तिर्यग्वर्ग", "diamond": "तिर्यग्वर्ग",
"ellipse": "दीर्घवृत्त", "ellipse": "दीर्घवृत्त",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Kijelölés", "selection": "Kijelölés",
"draw": "Szabadkézi rajz", "freedraw": "Szabadkézi rajz",
"rectangle": "Téglalap", "rectangle": "Téglalap",
"diamond": "Rombusz", "diamond": "Rombusz",
"ellipse": "Ellipszis", "ellipse": "Ellipszis",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Pilihan", "selection": "Pilihan",
"draw": "Menggambar bebas", "freedraw": "Menggambar bebas",
"rectangle": "Persegi", "rectangle": "Persegi",
"diamond": "Berlian", "diamond": "Berlian",
"ellipse": "Elips", "ellipse": "Elips",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selezione", "selection": "Selezione",
"draw": "Disegno libero", "freedraw": "Disegno libero",
"rectangle": "Rettangolo", "rectangle": "Rettangolo",
"diamond": "Rombo", "diamond": "Rombo",
"ellipse": "Ellisse", "ellipse": "Ellisse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "選択", "selection": "選択",
"draw": "手書き", "freedraw": "手書き",
"rectangle": "矩形", "rectangle": "矩形",
"diamond": "ひし形", "diamond": "ひし形",
"ellipse": "楕円", "ellipse": "楕円",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Tafrayt", "selection": "Tafrayt",
"draw": "Unuɣ ilelli", "freedraw": "Unuɣ ilelli",
"rectangle": "Asrem", "rectangle": "Asrem",
"diamond": "Ameɣṛun", "diamond": "Ameɣṛun",
"ellipse": "Taglayt", "ellipse": "Taglayt",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "선택", "selection": "선택",
"draw": "자유롭게 그리기", "freedraw": "자유롭게 그리기",
"rectangle": "사각형", "rectangle": "사각형",
"diamond": "다이아몬드", "diamond": "다이아몬드",
"ellipse": "타원", "ellipse": "타원",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "ရွေးချယ်", "selection": "ရွေးချယ်",
"draw": "အလွတ်ရေးဆွဲ", "freedraw": "အလွတ်ရေးဆွဲ",
"rectangle": "စတုဂံ", "rectangle": "စတုဂံ",
"diamond": "စိန်", "diamond": "စိန်",
"ellipse": "အဝိုင်း", "ellipse": "အဝိုင်း",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Velg", "selection": "Velg",
"draw": "Frihåndstegning", "freedraw": "Frihåndstegning",
"rectangle": "Rektangel", "rectangle": "Rektangel",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Ellipse", "ellipse": "Ellipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selectie", "selection": "Selectie",
"draw": "Vrij tekenen", "freedraw": "Vrij tekenen",
"rectangle": "Rechthoek", "rectangle": "Rechthoek",
"diamond": "Ruit", "diamond": "Ruit",
"ellipse": "Ovaal", "ellipse": "Ovaal",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Vel", "selection": "Vel",
"draw": "Frihandsteikning", "freedraw": "Frihandsteikning",
"rectangle": "Rektangel", "rectangle": "Rektangel",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Ellipse", "ellipse": "Ellipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Seleccion", "selection": "Seleccion",
"draw": "Dessenh liure", "freedraw": "Dessenh liure",
"rectangle": "Rectangle", "rectangle": "Rectangle",
"diamond": "Lausange", "diamond": "Lausange",
"ellipse": "Ellipsa", "ellipse": "Ellipsa",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "ਚੋਣਕਾਰ", "selection": "ਚੋਣਕਾਰ",
"draw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ", "freedraw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ",
"rectangle": "ਆਇਤ", "rectangle": "ਆਇਤ",
"diamond": "ਹੀਰਾ", "diamond": "ਹੀਰਾ",
"ellipse": "ਅੰਡਾਕਾਰ", "ellipse": "ਅੰਡਾਕਾਰ",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Zaznaczenie", "selection": "Zaznaczenie",
"draw": "Swobodne rysowanie", "freedraw": "Swobodne rysowanie",
"rectangle": "Prostokąt", "rectangle": "Prostokąt",
"diamond": "Romb", "diamond": "Romb",
"ellipse": "Elipsa", "ellipse": "Elipsa",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Seleção", "selection": "Seleção",
"draw": "Desenho livre", "freedraw": "Desenho livre",
"rectangle": "Retângulo", "rectangle": "Retângulo",
"diamond": "Losango", "diamond": "Losango",
"ellipse": "Elipse", "ellipse": "Elipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Seleção", "selection": "Seleção",
"draw": "Desenho livre", "freedraw": "Desenho livre",
"rectangle": "Retângulo", "rectangle": "Retângulo",
"diamond": "Losango", "diamond": "Losango",
"ellipse": "Elipse", "ellipse": "Elipse",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Selecție", "selection": "Selecție",
"draw": "Desenare liberă", "freedraw": "Desenare liberă",
"rectangle": "Dreptunghi", "rectangle": "Dreptunghi",
"diamond": "Romb", "diamond": "Romb",
"ellipse": "Elipsă", "ellipse": "Elipsă",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Выделение области", "selection": "Выделение области",
"draw": "Свободное рисование", "freedraw": "Свободное рисование",
"rectangle": "Прямоугольник", "rectangle": "Прямоугольник",
"diamond": "Ромб", "diamond": "Ромб",
"ellipse": "Эллипс", "ellipse": "Эллипс",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Výber", "selection": "Výber",
"draw": "Voľné kreslenie", "freedraw": "Voľné kreslenie",
"rectangle": "Obdĺžnik", "rectangle": "Obdĺžnik",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Elipsa", "ellipse": "Elipsa",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Markering", "selection": "Markering",
"draw": "Frihand", "freedraw": "Frihand",
"rectangle": "Rektangel", "rectangle": "Rektangel",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Ellips", "ellipse": "Ellips",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Seçme", "selection": "Seçme",
"draw": "Serbest çizim", "freedraw": "Serbest çizim",
"rectangle": "Dikdörtgen", "rectangle": "Dikdörtgen",
"diamond": "Elmas", "diamond": "Elmas",
"ellipse": "Elips", "ellipse": "Elips",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "Виділення", "selection": "Виділення",
"draw": "Вільне креслення", "freedraw": "Вільне креслення",
"rectangle": "Прямокутник", "rectangle": "Прямокутник",
"diamond": "Ромб", "diamond": "Ромб",
"ellipse": "Еліпс", "ellipse": "Еліпс",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "选择", "selection": "选择",
"draw": "自由书写", "freedraw": "自由书写",
"rectangle": "矩形", "rectangle": "矩形",
"diamond": "菱形", "diamond": "菱形",
"ellipse": "椭圆", "ellipse": "椭圆",

View File

@ -152,7 +152,7 @@
}, },
"toolBar": { "toolBar": {
"selection": "選取", "selection": "選取",
"draw": "繪圖", "freedraw": "繪圖",
"rectangle": "長方形", "rectangle": "長方形",
"diamond": "菱形", "diamond": "菱形",
"ellipse": "橢圓", "ellipse": "橢圓",

View File

@ -249,6 +249,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
return false; return false;
}; };
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = ( export const getGridPoint = (
x: number, x: number,
y: number, y: number,

View File

@ -8,6 +8,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
height: Math.max(...ys) - Math.min(...ys), height: Math.max(...ys) - Math.min(...ys),
}; };
}; };
export const rescalePoints = ( export const rescalePoints = (
dimension: 0 | 1, dimension: 0 | 1,
nextDimensionSize: number, nextDimensionSize: number,

View File

@ -4,8 +4,13 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
Arrowhead, Arrowhead,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
} from "../element/types"; } from "../element/types";
import { isTextElement, isLinearElement } from "../element/typeChecks"; import {
isTextElement,
isLinearElement,
isFreeDrawElement,
} from "../element/typeChecks";
import { import {
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -27,14 +32,17 @@ import { isPathALoop } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Zoom } from "../types"; import { Zoom } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import getFreeDrawShape from "perfect-freehand";
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const CANVAS_PADDING = 20;
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth]; const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
const getCanvasPadding = (element: ExcalidrawElement) =>
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
export interface ExcalidrawElementWithCanvas { export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement; element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@ -49,18 +57,25 @@ const generateElementCanvas = (
): ExcalidrawElementWithCanvas => { ): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element);
let canvasOffsetX = 0; let canvasOffsetX = 0;
let canvasOffsetY = 0; let canvasOffsetY = 0;
if (isLinearElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
x1 = Math.floor(x1);
x2 = Math.ceil(x2);
y1 = Math.floor(y1);
y2 = Math.ceil(y2);
canvas.width = canvas.width =
distance(x1, x2) * window.devicePixelRatio * zoom.value + distance(x1, x2) * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * zoom.value * 2; padding * zoom.value * 2;
canvas.height = canvas.height =
distance(y1, y2) * window.devicePixelRatio * zoom.value + distance(y1, y2) * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * zoom.value * 2; padding * zoom.value * 2;
canvasOffsetX = canvasOffsetX =
element.x > x1 element.x > x1
@ -80,13 +95,13 @@ const generateElementCanvas = (
} else { } else {
canvas.width = canvas.width =
element.width * window.devicePixelRatio * zoom.value + element.width * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * zoom.value * 2; padding * zoom.value * 2;
canvas.height = canvas.height =
element.height * window.devicePixelRatio * zoom.value + element.height * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * zoom.value * 2; padding * zoom.value * 2;
} }
context.translate(CANVAS_PADDING * zoom.value, CANVAS_PADDING * zoom.value); context.translate(padding * zoom.value, padding * zoom.value);
context.scale( context.scale(
window.devicePixelRatio * zoom.value, window.devicePixelRatio * zoom.value,
@ -94,11 +109,10 @@ const generateElementCanvas = (
); );
const rc = rough.canvas(canvas); const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context); drawElementOnCanvas(element, rc, context);
context.translate(
-(CANVAS_PADDING * zoom.value), context.translate(-(padding * zoom.value), -(padding * zoom.value));
-(CANVAS_PADDING * zoom.value),
);
context.scale( context.scale(
1 / (window.devicePixelRatio * zoom.value), 1 / (window.devicePixelRatio * zoom.value),
1 / (window.devicePixelRatio * zoom.value), 1 / (window.devicePixelRatio * zoom.value),
@ -138,6 +152,19 @@ const drawElementOnCanvas = (
}); });
break; break;
} }
case "freedraw": {
// Draw directly to canvas
context.save();
context.fillStyle = element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D;
context.fillStyle = element.strokeColor;
context.fill(path);
context.restore();
break;
}
default: { default: {
if (isTextElement(element)) { if (isTextElement(element)) {
const rtl = isRTL(element.text); const rtl = isRTL(element.text);
@ -243,10 +270,8 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
} }
return options; return options;
} }
case "line": case "draw":
case "draw": { case "line": {
// If shape is a line and is a closed shape,
// fill the shape if a color is set.
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = options.fill =
@ -256,6 +281,7 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
} }
return options; return options;
} }
case "freedraw":
case "arrow": case "arrow":
return options; return options;
default: { default: {
@ -264,11 +290,17 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
} }
}; };
/**
* Generates the element's shape and puts it into the cache.
* @param element
* @param generator
*/
const generateElementShape = ( const generateElementShape = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
generator: RoughGenerator, generator: RoughGenerator,
) => { ) => {
let shape = shapeCache.get(element) || null; let shape = shapeCache.get(element) || null;
if (!shape) { if (!shape) {
elementWithCanvasCache.delete(element); elementWithCanvasCache.delete(element);
@ -327,8 +359,8 @@ const generateElementShape = (
generateRoughOptions(element), generateRoughOptions(element),
); );
break; break;
case "line":
case "draw": case "draw":
case "line":
case "arrow": { case "arrow": {
const options = generateRoughOptions(element); const options = generateRoughOptions(element);
@ -380,15 +412,18 @@ const generateElementShape = (
...options, ...options,
fill: element.strokeColor, fill: element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
stroke: "none",
}), }),
]; ];
} }
// Arrow arrowheads // Arrow arrowheads
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") { if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible // for dotted arrows caps, reduce gap to make it more legible
options.strokeLineDash = [3, 4]; const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else { } else {
// for solid/dashed, keep solid arrow cap // for solid/dashed, keep solid arrow cap
delete options.strokeLineDash; delete options.strokeLineDash;
@ -423,6 +458,12 @@ const generateElementShape = (
shape.push(...shapes); shape.push(...shapes);
} }
} }
break;
}
case "freedraw": {
generateFreeDrawShape(element);
shape = [];
break; break;
} }
case "text": { case "text": {
@ -447,7 +488,9 @@ const generateElementWithCanvas = (
!sceneState?.shouldCacheIgnoreZoom; !sceneState?.shouldCacheIgnoreZoom;
if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) { if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
const elementWithCanvas = generateElementCanvas(element, zoom); const elementWithCanvas = generateElementCanvas(element, zoom);
elementWithCanvasCache.set(element, elementWithCanvas); elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas; return elementWithCanvas;
} }
return prevElementWithCanvas; return prevElementWithCanvas;
@ -460,20 +503,29 @@ const drawElementFromCanvas = (
sceneState: SceneState, sceneState: SceneState,
) => { ) => {
const element = elementWithCanvas.element; const element = elementWithCanvas.element;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const padding = getCanvasPadding(element);
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
// Free draw elements will otherwise "shuffle" as the min x and y change
if (isFreeDrawElement(element)) {
x1 = Math.floor(x1);
x2 = Math.ceil(x2);
y1 = Math.floor(y1);
y2 = Math.ceil(y2);
}
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio; const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio; const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(element.angle); context.rotate(element.angle);
context.drawImage( context.drawImage(
elementWithCanvas.canvas!, elementWithCanvas.canvas!,
(-(x2 - x1) / 2) * window.devicePixelRatio - (-(x2 - x1) / 2) * window.devicePixelRatio -
(CANVAS_PADDING * elementWithCanvas.canvasZoom) / (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
elementWithCanvas.canvasZoom,
(-(y2 - y1) / 2) * window.devicePixelRatio - (-(y2 - y1) / 2) * window.devicePixelRatio -
(CANVAS_PADDING * elementWithCanvas.canvasZoom) / (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
elementWithCanvas.canvasZoom,
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
); );
@ -508,11 +560,37 @@ export const renderElement = (
); );
break; break;
} }
case "freedraw": {
generateElementShape(element, generator);
if (renderOptimizations) {
const elementWithCanvas = generateElementWithCanvas(
element,
sceneState,
);
drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
} else {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2 + sceneState.scrollX;
const cy = (y1 + y2) / 2 + sceneState.scrollY;
const shiftX = (x2 - x1) / 2 - (element.x - x1);
const shiftY = (y2 - y1) / 2 - (element.y - y1);
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context);
context.translate(shiftX, shiftY);
context.rotate(-element.angle);
context.translate(-cx, -cy);
}
break;
}
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": case "ellipse":
case "line":
case "draw": case "draw":
case "line":
case "arrow": case "arrow":
case "text": { case "text": {
generateElementShape(element, generator); generateElementShape(element, generator);
@ -583,8 +661,8 @@ export const renderElementToSvg = (
svgRoot.appendChild(node); svgRoot.appendChild(node);
break; break;
} }
case "line":
case "draw": case "draw":
case "line":
case "arrow": { case "arrow": {
generateElementShape(element, generator); generateElementShape(element, generator);
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
@ -604,7 +682,7 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
if ( if (
(element.type === "line" || element.type === "draw") && element.type === "line" &&
isPathALoop(element.points) && isPathALoop(element.points) &&
element.backgroundColor !== "transparent" element.backgroundColor !== "transparent"
) { ) {
@ -615,6 +693,28 @@ export const renderElementToSvg = (
svgRoot.appendChild(group); svgRoot.appendChild(group);
break; break;
} }
case "freedraw": {
generateFreeDrawShape(element);
const opacity = element.opacity / 100;
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
node.setAttribute("stroke", "none");
node.setAttribute("fill", element.strokeStyle);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
svgRoot.appendChild(node);
break;
}
default: { default: {
if (isTextElement(element)) { if (isTextElement(element)) {
const opacity = element.opacity / 100; const opacity = element.opacity / 100;
@ -666,3 +766,55 @@ export const renderElementToSvg = (
} }
} }
}; };
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
const svgPathData = getFreeDrawSvgPath(element);
const path = new Path2D(svgPathData);
pathsCache.set(element, path);
return path;
}
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element);
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0]];
// Consider changing the options for simulated pressure vs real pressure
const options = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 6,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
easing: (t: number) => t * (2 - t),
last: true,
};
const points = getFreeDrawShape(inputPoints as number[][], options);
const d: (string | number)[] = [];
let [p0, p1] = points;
d.push("M", p0[0], p0[1], "Q");
for (let i = 0; i < points.length; i++) {
d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
p0 = p1;
p1 = points[i];
}
p1 = points[0];
d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
d.push("Z");
return d.join(" ");
}

View File

@ -201,11 +201,12 @@ export const renderScene = (
renderGrid?: boolean; renderGrid?: boolean;
} = {}, } = {},
) => { ) => {
if (!canvas) { if (canvas === null) {
return { atLeastOneVisibleElement: false }; return { atLeastOneVisibleElement: false };
} }
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
context.scale(scale, scale); context.scale(scale, scale);
// When doing calculations based on canvas width we should used normalized one // When doing calculations based on canvas width we should used normalized one

View File

@ -9,22 +9,25 @@ export const hasBackground = (type: string) =>
type === "rectangle" || type === "rectangle" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "draw" ||
type === "line"; type === "line";
export const hasStroke = (type: string) => export const hasStrokeWidth = (type: string) =>
type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line";
export const hasStrokeStyle = (type: string) =>
type === "rectangle" || type === "rectangle" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "arrow" || type === "arrow" ||
type === "draw" ||
type === "line"; type === "line";
export const canChangeSharpness = (type: string) => export const canChangeSharpness = (type: string) =>
type === "rectangle" || type === "rectangle" || type === "arrow" || type === "line";
type === "arrow" ||
type === "draw" ||
type === "line";
export const hasText = (type: string) => type === "text"; export const hasText = (type: string) => type === "text";

View File

@ -9,7 +9,8 @@ export {
export { calculateScrollCenter } from "./scroll"; export { calculateScrollCenter } from "./scroll";
export { export {
hasBackground, hasBackground,
hasStroke, hasStrokeWidth,
hasStrokeStyle,
canHaveArrowheads, canHaveArrowheads,
canChangeSharpness, canChangeSharpness,
getElementAtPosition, getElementAtPosition,

View File

@ -80,7 +80,7 @@ export const SHAPES = [
></path> ></path>
</svg> </svg>
), ),
value: "draw", value: "freedraw",
key: KEYS.X, key: KEYS.X,
}, },
{ {

View File

@ -6280,7 +6280,7 @@ Object {
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"elementLocked": false, "elementLocked": false,
"elementType": "draw", "elementType": "freedraw",
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
"exportEmbedScene": false, "exportEmbedScene": false,
@ -6596,8 +6596,6 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -6614,18 +6612,26 @@ Object {
50, 50,
10, 10,
], ],
Array [
50,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 941653321, "seed": 941653321,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 1402203177, "versionNonce": 1359939303,
"width": 50, "width": 50,
"x": 550, "x": 550,
"y": -10, "y": -10,
@ -8246,8 +8252,6 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -8264,18 +8268,26 @@ Object {
50, 50,
10, 10,
], ],
Array [
50,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 941653321, "seed": 941653321,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 1402203177, "versionNonce": 1359939303,
"width": 50, "width": 50,
"x": 550, "x": 550,
"y": -10, "y": -10,
@ -10355,7 +10367,7 @@ exports[`regression tests key 6 selects line tool: [end of test] number of eleme
exports[`regression tests key 6 selects line tool: [end of test] number of renders 1`] = `8`; exports[`regression tests key 6 selects line tool: [end of test] number of renders 1`] = `8`;
exports[`regression tests key 7 selects draw tool: [end of test] appState 1`] = ` exports[`regression tests key 7 selects freedraw tool: [end of test] appState 1`] = `
Object { Object {
"collaborators": Map {}, "collaborators": Map {},
"currentChartType": "bar", "currentChartType": "bar",
@ -10379,7 +10391,7 @@ Object {
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"elementLocked": false, "elementLocked": false,
"elementType": "draw", "elementType": "freedraw",
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
"exportEmbedScene": false, "exportEmbedScene": false,
@ -10434,13 +10446,11 @@ Object {
} }
`; `;
exports[`regression tests key 7 selects draw tool: [end of test] element 0 1`] = ` exports[`regression tests key 7 selects freedraw tool: [end of test] element 0 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -10457,25 +10467,33 @@ Object {
10, 10,
10, 10,
], ],
Array [
10,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 449462985, "versionNonce": 453191,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 10, "y": 10,
} }
`; `;
exports[`regression tests key 7 selects draw tool: [end of test] history 1`] = ` exports[`regression tests key 7 selects freedraw tool: [end of test] history 1`] = `
Object { Object {
"recording": false, "recording": false,
"redoStack": Array [], "redoStack": Array [],
@ -10505,8 +10523,6 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -10523,18 +10539,26 @@ Object {
10, 10,
10, 10,
], ],
Array [
10,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 449462985, "versionNonce": 453191,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -10545,9 +10569,9 @@ Object {
} }
`; `;
exports[`regression tests key 7 selects draw tool: [end of test] number of elements 1`] = `1`; exports[`regression tests key 7 selects freedraw tool: [end of test] number of elements 1`] = `1`;
exports[`regression tests key 7 selects draw tool: [end of test] number of renders 1`] = `8`; exports[`regression tests key 7 selects freedraw tool: [end of test] number of renders 1`] = `8`;
exports[`regression tests key a selects arrow tool: [end of test] appState 1`] = ` exports[`regression tests key a selects arrow tool: [end of test] appState 1`] = `
Object { Object {
@ -11429,7 +11453,7 @@ exports[`regression tests key r selects rectangle tool: [end of test] number of
exports[`regression tests key r selects rectangle tool: [end of test] number of renders 1`] = `8`; exports[`regression tests key r selects rectangle tool: [end of test] number of renders 1`] = `8`;
exports[`regression tests key x selects draw tool: [end of test] appState 1`] = ` exports[`regression tests key x selects freedraw tool: [end of test] appState 1`] = `
Object { Object {
"collaborators": Map {}, "collaborators": Map {},
"currentChartType": "bar", "currentChartType": "bar",
@ -11453,7 +11477,7 @@ Object {
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"elementLocked": false, "elementLocked": false,
"elementType": "draw", "elementType": "freedraw",
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
"exportEmbedScene": false, "exportEmbedScene": false,
@ -11508,13 +11532,11 @@ Object {
} }
`; `;
exports[`regression tests key x selects draw tool: [end of test] element 0 1`] = ` exports[`regression tests key x selects freedraw tool: [end of test] element 0 1`] = `
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -11531,25 +11553,33 @@ Object {
10, 10,
10, 10,
], ],
Array [
10,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 449462985, "versionNonce": 453191,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 10, "y": 10,
} }
`; `;
exports[`regression tests key x selects draw tool: [end of test] history 1`] = ` exports[`regression tests key x selects freedraw tool: [end of test] history 1`] = `
Object { Object {
"recording": false, "recording": false,
"redoStack": Array [], "redoStack": Array [],
@ -11579,8 +11609,6 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
"height": 10, "height": 10,
@ -11597,18 +11625,26 @@ Object {
10, 10,
10, 10,
], ],
Array [
10,
10,
],
],
"pressures": Array [
0,
0,
0,
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null, "simulatePressure": false,
"startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "draw", "type": "freedraw",
"version": 3, "version": 4,
"versionNonce": 449462985, "versionNonce": 453191,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -11619,9 +11655,9 @@ Object {
} }
`; `;
exports[`regression tests key x selects draw tool: [end of test] number of elements 1`] = `1`; exports[`regression tests key x selects freedraw tool: [end of test] number of elements 1`] = `1`;
exports[`regression tests key x selects draw tool: [end of test] number of renders 1`] = `8`; exports[`regression tests key x selects freedraw tool: [end of test] number of renders 1`] = `8`;
exports[`regression tests make a group and duplicate it: [end of test] appState 1`] = ` exports[`regression tests make a group and duplicate it: [end of test] appState 1`] = `
Object { Object {

View File

@ -71,7 +71,7 @@ const createAndSelectOneLine = (angle: number = 0) => {
}; };
const createAndReturnOneDraw = (angle: number = 0) => { const createAndReturnOneDraw = (angle: number = 0) => {
return UI.createElement("draw", { return UI.createElement("freedraw", {
x: 0, x: 0,
y: 0, y: 0,
width: 50, width: 50,

View File

@ -3,6 +3,7 @@ import {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawFreeDrawElement,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
@ -12,6 +13,7 @@ import fs from "fs";
import util from "util"; import util from "util";
import path from "path"; import path from "path";
import { getMimeType } from "../../data/blob"; import { getMimeType } from "../../data/blob";
import { newFreeDrawElement } from "../../element/newElement";
const readFile = util.promisify(fs.readFile); const readFile = util.promisify(fs.readFile);
@ -81,8 +83,10 @@ export class API {
verticalAlign?: T extends "text" verticalAlign?: T extends "text"
? ExcalidrawTextElement["verticalAlign"] ? ExcalidrawTextElement["verticalAlign"]
: never; : never;
}): T extends "arrow" | "line" | "draw" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "freedraw"
? ExcalidrawFreeDrawElement
: T extends "text" : T extends "text"
? ExcalidrawTextElement ? ExcalidrawTextElement
: ExcalidrawGenericElement => { : ExcalidrawGenericElement => {
@ -125,11 +129,17 @@ export class API {
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
}); });
break; break;
case "freedraw":
element = newFreeDrawElement({
type: type as "freedraw",
simulatePressure: true,
...base,
});
break;
case "arrow": case "arrow":
case "line": case "line":
case "draw":
element = newLinearElement({ element = newLinearElement({
type: type as "arrow" | "line" | "draw", type: type as "arrow" | "line",
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
...base, ...base,

View File

@ -213,14 +213,14 @@ export class UI {
height?: number; height?: number;
angle?: number; angle?: number;
} = {}, } = {},
): (T extends "arrow" | "line" | "draw" ): (T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "text" : T extends "text"
? ExcalidrawTextElement ? ExcalidrawTextElement
: ExcalidrawElement) & { : ExcalidrawElement) & {
/** Returns the actual, current element from the elements array, instead /** Returns the actual, current element from the elements array, instead
of the proxy */ of the proxy */
get(): T extends "arrow" | "line" | "draw" get(): T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "text" : T extends "text"
? ExcalidrawTextElement ? ExcalidrawTextElement

View File

@ -7,7 +7,7 @@ const toolMap = {
ellipse: "ellipse", ellipse: "ellipse",
arrow: "arrow", arrow: "arrow",
line: "line", line: "line",
draw: "draw", freedraw: "freedraw",
text: "text", text: "text",
}; };

View File

@ -106,7 +106,7 @@ describe("regression tests", () => {
mouse.click(30, 10); mouse.click(30, 10);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
UI.clickTool("draw"); UI.clickTool("freedraw");
mouse.down(40, -20); mouse.down(40, -20);
mouse.up(50, 10); mouse.up(50, 10);
@ -118,7 +118,7 @@ describe("regression tests", () => {
"line", "line",
"arrow", "arrow",
"line", "line",
"draw", "freedraw",
]); ]);
}); });
@ -146,7 +146,7 @@ describe("regression tests", () => {
[`4${KEYS.E}`, "ellipse", true], [`4${KEYS.E}`, "ellipse", true],
[`5${KEYS.A}`, "arrow", true], [`5${KEYS.A}`, "arrow", true],
[`6${KEYS.L}`, "line", true], [`6${KEYS.L}`, "line", true],
[`7${KEYS.X}`, "draw", false], [`7${KEYS.X}`, "freedraw", false],
] as [string, ExcalidrawElement["type"], boolean][]) { ] as [string, ExcalidrawElement["type"], boolean][]) {
for (const key of keys) { for (const key of keys) {
it(`key ${key} selects ${shape} tool`, () => { it(`key ${key} selects ${shape} tool`, () => {

View File

@ -9249,6 +9249,11 @@ pepjs@0.5.3:
version "0.5.3" version "0.5.3"
resolved "https://registry.npmjs.org/pepjs/-/pepjs-0.5.3.tgz" resolved "https://registry.npmjs.org/pepjs/-/pepjs-0.5.3.tgz"
perfect-freehand@0.4.7:
version "0.4.7"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-0.4.7.tgz#4d85fd64881ba81b2a4eaa6ac4e8983ccb21dd43"
integrity sha512-SSSFL8VzXiOHQdUTyNyOb0JC+btVZRy9bi6jos7Nb7PBTI0PHX5jM6RgCTSrubQ8Ul9qOYWmWgJBrwVGHwyJZQ==
performance-now@^2.1.0: performance-now@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"