diff --git a/package.json b/package.json index 2d4c96bb..424bf546 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "nanoid": "3.1.22", "open-color": "1.8.0", "pako": "1.0.11", + "perfect-freehand": "0.4.7", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index d314f1a3..3a80734e 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -56,14 +56,14 @@ export const actionFinalize = register({ const multiPointElement = appState.multiElement ? appState.multiElement - : appState.editingElement?.type === "draw" + : appState.editingElement?.type === "freedraw" ? appState.editingElement : null; if (multiPointElement) { // pen and mouse have hover if ( - multiPointElement.type !== "draw" && + multiPointElement.type !== "freedraw" && appState.lastPointerDownWith !== "touch" ) { const { points, lastCommittedPoint } = multiPointElement; @@ -86,7 +86,7 @@ export const actionFinalize = register({ const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); if ( multiPointElement.type === "line" || - multiPointElement.type === "draw" + multiPointElement.type === "freedraw" ) { if (isLoop) { 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; } } + if ( - (!appState.elementLocked && appState.elementType !== "draw") || + (!appState.elementLocked && appState.elementType !== "freedraw") || !multiPointElement ) { resetCursor(canvas); } + return { elements: newElements, appState: { ...appState, elementType: - (appState.elementLocked || appState.elementType === "draw") && + (appState.elementLocked || appState.elementType === "freedraw") && multiPointElement ? appState.elementType : "selection", @@ -145,14 +147,14 @@ export const actionFinalize = register({ selectedElementIds: multiPointElement && !appState.elementLocked && - appState.elementType !== "draw" + appState.elementType !== "freedraw" ? { ...appState.selectedElementIds, [multiPointElement.id]: true, } : appState.selectedElementIds, }, - commitToHistory: appState.elementType === "draw", + commitToHistory: appState.elementType === "freedraw", }; }, keyTest: (event, appState) => diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index 7be77d5d..a9c200a0 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types"; import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { AppState } from "../types"; import { getTransformHandles } from "../element/transformHandles"; -import { isLinearElement } from "../element/typeChecks"; +import { isFreeDrawElement, isLinearElement } from "../element/typeChecks"; import { updateBoundElements } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -114,7 +114,7 @@ const flipElement = ( const originalAngle = normalizeAngle(element.angle); let finalOffsetX = 0; - if (isLinearElement(element)) { + if (isLinearElement(element) || isFreeDrawElement(element)) { finalOffsetX = element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - element.width; diff --git a/src/actions/types.ts b/src/actions/types.ts index 4e1c550e..7187a0cc 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -52,6 +52,7 @@ export type ActionName = | "changeBackgroundColor" | "changeFillStyle" | "changeStrokeWidth" + | "changeStrokeShape" | "changeSloppiness" | "changeStrokeStyle" | "changeArrowhead" diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 6835b920..e7ced60c 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -9,7 +9,8 @@ import { canHaveArrowheads, getTargetElements, hasBackground, - hasStroke, + hasStrokeStyle, + hasStrokeWidth, hasText, } from "../scene"; import { SHAPES } from "../shapes"; @@ -53,10 +54,17 @@ export const SelectedShapeActions = ({ {showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showFillIcons && renderAction("changeFillStyle")} - {(hasStroke(elementType) || - targetElements.some((element) => hasStroke(element.type))) && ( + {(hasStrokeWidth(elementType) || + 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("changeSloppiness")} diff --git a/src/components/App.tsx b/src/components/App.tsx index 472081be..b666cb21 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,4 +1,3 @@ -import { Point, simplify } from "points-on-curve"; import React, { useContext } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; @@ -70,7 +69,7 @@ import { import { loadFromBlob } from "../data"; import { isValidLibrary } from "../data/json"; import Library from "../data/library"; -import { restore } from "../data/restore"; +import { restore, restoreElements } from "../data/restore"; import { dragNewElement, dragSelectedElements, @@ -111,7 +110,7 @@ import { } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement } from "../element/mutateElement"; -import { deepCopyElement } from "../element/newElement"; +import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { MaybeTransformHandleType } from "../element/transformHandles"; import { isBindingElement, @@ -122,6 +121,7 @@ import { import { ExcalidrawBindableElement, ExcalidrawElement, + ExcalidrawFreeDrawElement, ExcalidrawGenericElement, ExcalidrawLinearElement, ExcalidrawTextElement, @@ -1266,7 +1266,7 @@ class App extends React.Component { }); } else if (data.elements) { this.addElementsFromPasteOrLibrary({ - elements: data.elements, + elements: restoreElements(data.elements), position: "cursor", }); } else if (data.text) { @@ -2341,7 +2341,6 @@ class App extends React.Component { return; } else if ( this.state.elementType === "arrow" || - this.state.elementType === "draw" || this.state.elementType === "line" ) { this.handleLinearElementOnPointerDown( @@ -2349,6 +2348,12 @@ class App extends React.Component { this.state.elementType, pointerDownState, ); + } else if (this.state.elementType === "freedraw") { + this.handleFreeDrawElementOnPointerDown( + event, + this.state.elementType, + pointerDownState, + ); } else { this.createGenericElementOnPointerDown( this.state.elementType, @@ -2845,6 +2850,65 @@ class App extends React.Component { } }; + private handleFreeDrawElementOnPointerDown = ( + event: React.PointerEvent, + 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 = ( event: React.PointerEvent, elementType: ExcalidrawLinearElement["type"], @@ -2899,7 +2963,7 @@ class App extends React.Component { const [gridX, gridY] = getGridPoint( pointerDownState.origin.x, 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. @@ -3107,6 +3171,7 @@ class App extends React.Component { const hasHitASelectedElement = pointerDownState.hit.allHitElements.some( (element) => this.isASelectedElement(element), ); + if ( hasHitASelectedElement || pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements @@ -3207,18 +3272,24 @@ class App extends React.Component { 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; const points = draggingElement.points; - let dx: number; - let dy: number; - if (draggingElement.type === "draw") { - dx = pointerCoords.x - draggingElement.x; - dy = pointerCoords.y - draggingElement.y; - } else { - dx = gridX - draggingElement.x; - dy = gridY - draggingElement.y; - } + let dx = gridX - draggingElement.x; + let dy = gridY - draggingElement.y; if (getRotateWithDiscreteAngleKey(event) && points.length === 2) { ({ width: dx, height: dy } = getPerfectElementSize( @@ -3231,19 +3302,11 @@ class App extends React.Component { if (points.length === 1) { mutateElement(draggingElement, { points: [...points, [dx, dy]] }); } else if (points.length > 1) { - if (draggingElement.type === "draw") { - mutateElement(draggingElement, { - points: simplify( - [...(points as Point[]), [dx, dy]], - 0.7 / this.state.zoom.value, - ), - }); - } else { - mutateElement(draggingElement, { - points: [...points.slice(0, -1), [dx, dy]], - }); - } + mutateElement(draggingElement, { + points: [...points.slice(0, -1), [dx, dy]], + }); } + if (isBindingElement(draggingElement)) { // When creating a linear element by dragging this.maybeSuggestBindingForLinearElementAtCursor( @@ -3383,8 +3446,33 @@ class App extends React.Component { 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); + return; } @@ -3428,7 +3516,7 @@ class App extends React.Component { ); } this.setState({ suggestedBindings: [], startBoundElement: null }); - if (!elementLocked && elementType !== "draw") { + if (!elementLocked) { resetCursor(this.canvas); this.setState((prevState) => ({ draggingElement: null, @@ -3575,7 +3663,7 @@ class App extends React.Component { return; } - if (!elementLocked && elementType !== "draw" && draggingElement) { + if (!elementLocked && elementType !== "freedraw" && draggingElement) { this.setState((prevState) => ({ selectedElementIds: { ...prevState.selectedElementIds, @@ -3599,7 +3687,7 @@ class App extends React.Component { ); } - if (!elementLocked && elementType !== "draw") { + if (!elementLocked && elementType !== "freedraw") { resetCursor(this.canvas); this.setState({ draggingElement: null, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index b11ac711..17191d18 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -153,7 +153,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 0b9ab568..4b1e84bf 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => { return t("hints.linearElementMulti"); } - if (elementType === "draw") { + if (elementType === "freedraw") { return t("hints.freeDraw"); } diff --git a/src/data/restore.ts b/src/data/restore.ts index a63a48c7..fab3fe65 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -37,10 +37,12 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamily => { const restoreElementWithProperties = ( element: Required, - extra: Omit, keyof ExcalidrawElement>, + extra: Omit, keyof ExcalidrawElement> & { + type?: ExcalidrawElement["type"]; + }, ): T => { const base: Pick = { - type: element.type, + type: extra.type || element.type, // all elements must have version > 0 so getSceneVersion() will pick up // newly added elements version: element.version || 1, @@ -97,6 +99,14 @@ const restoreElement = ( textAlign: element.textAlign || DEFAULT_TEXT_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 "line": case "arrow": { @@ -106,6 +116,7 @@ const restoreElement = ( } = element; return restoreElementWithProperties(element, { + type: element.type === "draw" ? "line" : element.type, startBinding: element.startBinding, endBinding: element.endBinding, points: diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 2641b941..0958db1b 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -1,4 +1,9 @@ -import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types"; +import { + ExcalidrawElement, + ExcalidrawLinearElement, + Arrowhead, + ExcalidrawFreeDrawElement, +} from "./types"; import { distance2d, rotate } from "../math"; import rough from "roughjs/bin/rough"; import { Drawable, Op } from "roughjs/bin/core"; @@ -7,7 +12,7 @@ import { getShapeForElement, generateRoughOptions, } from "../renderer/renderElement"; -import { isLinearElement } from "./typeChecks"; +import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { rescalePoints } from "../points"; // 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 = ( element: ExcalidrawElement, ): Bounds => { - if (isLinearElement(element)) { + if (isFreeDrawElement(element)) { + return getFreeDrawElementAbsoluteCoords(element); + } else if (isLinearElement(element)) { return getLinearElementAbsoluteCoords(element); } return [ @@ -120,9 +127,42 @@ const getMinMaxXYFromCurvePathOps = ( 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 = ( element: ExcalidrawLinearElement, ): [number, number, number, number] => { + let coords: [number, number, number, number]; + if (element.points.length < 2 || !getShapeForElement(element)) { // XXX this is just a poor estimate and not very useful const { minX, minY, maxX, maxY } = element.points.reduce( @@ -137,7 +177,21 @@ const getLinearElementAbsoluteCoords = ( }, { 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, minY + element.y, maxX + element.x, @@ -145,19 +199,7 @@ const getLinearElementAbsoluteCoords = ( ]; } - const shape = getShapeForElement(element) as Drawable[]; - - // 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, - ]; + return coords; }; export const getArrowheadPoints = ( @@ -231,7 +273,7 @@ export const getArrowheadPoints = ( const ys = y2 - ny * minSize; 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]; } @@ -277,16 +319,31 @@ const getLinearElementRotatedBounds = ( return getMinMaxXYFromCurvePathOps(ops, transformXY); }; +// We could cache this stuff export const getElementBounds = ( element: ExcalidrawElement, ): [number, number, number, number] => { + let bounds: [number, number, number, number]; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - if (isLinearElement(element)) { - return getLinearElementRotatedBounds(element, cx, cy); - } - if (element.type === "diamond") { + if (isFreeDrawElement(element)) { + const [minX, minY, maxX, maxY] = getBoundsFromPoints( + element.points.map(([x, y]) => + 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 [x12, y12] = rotate(cx, y2, 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 maxX = Math.max(x11, x12, x22, x21); const maxY = Math.max(y11, y12, y22, y21); - return [minX, minY, maxX, maxY]; - } - if (element.type === "ellipse") { + bounds = [minX, minY, maxX, maxY]; + } else if (element.type === "ellipse") { const w = (x2 - x1) / 2; const h = (y2 - y1) / 2; const cos = Math.cos(element.angle); const sin = Math.sin(element.angle); const ww = Math.hypot(w * cos, h * 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); - 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]; + + return bounds; }; export const getCommonBounds = ( @@ -345,7 +404,7 @@ export const getResizedElementAbsoluteCoords = ( nextWidth: number, nextHeight: number, ): [number, number, number, number] => { - if (!isLinearElement(element)) { + if (!(isLinearElement(element) || isFreeDrawElement(element))) { return [ element.x, element.y, @@ -360,16 +419,29 @@ export const getResizedElementAbsoluteCoords = ( rescalePoints(1, nextHeight, element.points), ); - const gen = rough.generator(); - const curve = - element.strokeSharpness === "sharp" - ? gen.linearPath( - points as [number, number][], - generateRoughOptions(element), - ) - : gen.curve(points as [number, number][], generateRoughOptions(element)); - const ops = getCurvePathOps(curve); - const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + let bounds: [number, number, number, number]; + + if (isFreeDrawElement(element)) { + // Free Draw + bounds = getBoundsFromPoints(points); + } else { + // Line + const gen = rough.generator(); + const curve = + 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 [ minX + element.x, minY + element.y, diff --git a/src/element/collision.ts b/src/element/collision.ts index 9a2d36f4..101968ca 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -4,7 +4,13 @@ import * as GADirection from "../gadirections"; import * as GALine from "../galines"; 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 { @@ -16,6 +22,7 @@ import { ExcalidrawTextElement, ExcalidrawEllipseElement, NonDeleted, + ExcalidrawFreeDrawElement, } from "./types"; import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; @@ -30,10 +37,17 @@ const isElementDraggableFromInside = ( if (element.type === "arrow") { return false; } + + if (element.type === "freedraw") { + return true; + } + const isDraggableFromInside = element.backgroundColor !== "transparent"; - if (element.type === "line" || element.type === "draw") { + + if (element.type === "line") { return isDraggableFromInside && isPathALoop(element.points); } + return isDraggableFromInside; }; @@ -81,6 +95,7 @@ const isHittingElementNotConsideringBoundingBox = ( : isElementDraggableFromInside(element) ? isInsideCheck : isNearCheck; + return hitTestPointAgainstElement({ element, point, threshold, check }); }; @@ -151,6 +166,18 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { case "ellipse": const distance = distanceToBindableElement(args.element, args.point); 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 "line": case "draw": @@ -195,7 +222,10 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => { }; const distanceToRectangle = ( - element: ExcalidrawRectangleElement | ExcalidrawTextElement, + element: + | ExcalidrawRectangleElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement, point: Point, ): number => { const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); @@ -267,6 +297,71 @@ const ellipseParamsForTest = ( 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 { element, threshold } = args; if (!getShapeForElement(element)) { diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 57f13124..5cc820ab 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -9,6 +9,7 @@ import { GroupId, VerticalAlign, Arrowhead, + ExcalidrawFreeDrawElement, } from "../element/types"; import { measureText, getFontString } from "../utils"; 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 => { + return { + ..._newElementBase(opts.type, opts), + points: opts.points || [], + pressures: [], + simulatePressure: opts.simulatePressure, + lastCommittedPoint: null, + }; +}; + export const newLinearElement = ( opts: { type: ExcalidrawLinearElement["type"]; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index b8a1a65f..28c551c7 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -18,7 +18,11 @@ import { getCommonBounds, getResizedElementAbsoluteCoords, } from "./bounds"; -import { isLinearElement, isTextElement } from "./typeChecks"; +import { + isFreeDrawElement, + isLinearElement, + isTextElement, +} from "./typeChecks"; import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; import { measureText, getFontString } from "../utils"; @@ -244,7 +248,7 @@ const rescalePointsInElement = ( width: number, height: number, ) => - isLinearElement(element) + isLinearElement(element) || isFreeDrawElement(element) ? { points: rescalePoints( 0, @@ -404,7 +408,7 @@ export const resizeSingleElement = ( -stateAtResizeStart.angle, ); - //Get bounds corners rendered on screen + // Get bounds corners rendered on screen const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords( element, element.width, @@ -644,11 +648,14 @@ const resizeMultipleElements = ( font = { fontSize: nextFont.size, baseline: nextFont.baseline }; } const origCoords = getElementAbsoluteCoords(element); + const rescaledPoints = rescalePointsInElement(element, width, height); + updateBoundElements(element, { newSize: { width, height }, simultaneouslyUpdated: elements, }); + const finalCoords = getResizedElementAbsoluteCoords( { ...element, @@ -657,6 +664,7 @@ const resizeMultipleElements = ( width, height, ); + const { x, y } = getNextXY(element, origCoords, finalCoords); return [...prev, { width, height, x, y, ...rescaledPoints, ...font }]; }, diff --git a/src/element/sizeHelpers.ts b/src/element/sizeHelpers.ts index a5c99544..d3701922 100644 --- a/src/element/sizeHelpers.ts +++ b/src/element/sizeHelpers.ts @@ -1,12 +1,12 @@ import { ExcalidrawElement } from "./types"; import { mutateElement } from "./mutateElement"; -import { isLinearElement } from "./typeChecks"; +import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { SHIFT_LOCKING_ANGLE } from "../constants"; export const isInvisiblySmallElement = ( element: ExcalidrawElement, ): boolean => { - if (isLinearElement(element)) { + if (isLinearElement(element) || isFreeDrawElement(element)) { return element.points.length < 2; } return element.width === 0 && element.height === 0; @@ -26,7 +26,7 @@ export const getPerfectElementSize = ( if ( elementType === "line" || elementType === "arrow" || - elementType === "draw" + elementType === "freedraw" ) { const lockedAngle = Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) * diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts index 579dd539..d60d0734 100644 --- a/src/element/transformHandles.ts +++ b/src/element/transformHandles.ts @@ -225,7 +225,7 @@ export const getTransformHandles = ( if ( element.type === "arrow" || element.type === "line" || - element.type === "draw" + element.type === "freedraw" ) { if (element.points.length === 2) { // only check the last point because starting point is always (0,0) diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 97b008b0..1825d996 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -4,6 +4,7 @@ import { ExcalidrawLinearElement, ExcalidrawBindableElement, ExcalidrawGenericElement, + ExcalidrawFreeDrawElement, } from "./types"; export const isGenericElement = ( @@ -24,6 +25,18 @@ export const isTextElement = ( 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 = ( element?: ExcalidrawElement | null, ): element is ExcalidrawLinearElement => { @@ -34,7 +47,7 @@ export const isLinearElementType = ( elementType: ExcalidrawElement["type"], ): boolean => { 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 === "ellipse" || element?.type === "arrow" || - element?.type === "draw" || + element?.type === "freedraw" || element?.type === "line" ); }; diff --git a/src/element/types.ts b/src/element/types.ts index baeb001a..f50e6af9 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -78,7 +78,8 @@ export type ExcalidrawGenericElement = export type ExcalidrawElement = | ExcalidrawGenericElement | ExcalidrawTextElement - | ExcalidrawLinearElement; + | ExcalidrawLinearElement + | ExcalidrawFreeDrawElement; export type NonDeleted = TElement & { isDeleted: false; @@ -121,3 +122,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & startArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null; }>; + +export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & + Readonly<{ + type: "freedraw"; + points: readonly Point[]; + pressures: readonly number[]; + simulatePressure: boolean; + lastCommittedPoint: Point | null; + }>; diff --git a/src/locales/ar-SA.json b/src/locales/ar-SA.json index 258c5b61..d4827b94 100644 --- a/src/locales/ar-SA.json +++ b/src/locales/ar-SA.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "تحديد", - "draw": "الكتابة الحرة", + "freedraw": "الكتابة الحرة", "rectangle": "مستطيل", "diamond": "مضلع", "ellipse": "دائرة", diff --git a/src/locales/bg-BG.json b/src/locales/bg-BG.json index 1f45dfd7..e23d98e2 100644 --- a/src/locales/bg-BG.json +++ b/src/locales/bg-BG.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Селекция", - "draw": "Рисуване", + "freedraw": "Рисуване", "rectangle": "Правоъгълник", "diamond": "Диамант", "ellipse": "Елипс", diff --git a/src/locales/ca-ES.json b/src/locales/ca-ES.json index 7a30328c..8e496d9b 100644 --- a/src/locales/ca-ES.json +++ b/src/locales/ca-ES.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Selecció", - "draw": "Dibuix lliure", + "freedraw": "Dibuix lliure", "rectangle": "Rectangle", "diamond": "Rombe", "ellipse": "El·lipse", diff --git a/src/locales/de-DE.json b/src/locales/de-DE.json index 22dadaca..3e5691bc 100644 --- a/src/locales/de-DE.json +++ b/src/locales/de-DE.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Auswahl", - "draw": "Freies Zeichnen", + "freedraw": "Freies Zeichnen", "rectangle": "Rechteck", "diamond": "Raute", "ellipse": "Ellipse", diff --git a/src/locales/el-GR.json b/src/locales/el-GR.json index bfac3ff8..17fecec0 100644 --- a/src/locales/el-GR.json +++ b/src/locales/el-GR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Επιλογή", - "draw": "Ελεύθερο σχέδιο", + "freedraw": "Ελεύθερο σχέδιο", "rectangle": "Ορθογώνιο", "diamond": "Ρόμβος", "ellipse": "Έλλειψη", diff --git a/src/locales/en.json b/src/locales/en.json index 96efc0fd..e4ccf132 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -20,6 +20,10 @@ "background": "Background", "fill": "Fill", "strokeWidth": "Stroke width", + "strokeShape": "Stroke shape", + "strokeShape_gel": "Gel pen", + "strokeShape_fountain": "Fountain pen", + "strokeShape_brush": "Brush pen", "strokeStyle": "Stroke style", "strokeStyle_solid": "Solid", "strokeStyle_dashed": "Dashed", @@ -153,7 +157,6 @@ }, "toolBar": { "selection": "Selection", - "draw": "Free draw", "rectangle": "Rectangle", "diamond": "Diamond", "ellipse": "Ellipse", diff --git a/src/locales/es-ES.json b/src/locales/es-ES.json index 8ce30788..77b0ba65 100644 --- a/src/locales/es-ES.json +++ b/src/locales/es-ES.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Selección", - "draw": "Dibujo libre", + "freedraw": "Dibujo libre", "rectangle": "Rectángulo", "diamond": "Diamante", "ellipse": "Elipse", diff --git a/src/locales/fa-IR.json b/src/locales/fa-IR.json index bb1a594b..db2d1577 100644 --- a/src/locales/fa-IR.json +++ b/src/locales/fa-IR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "گزینش", - "draw": "طراحی آزاد", + "freedraw": "طراحی آزاد", "rectangle": "مستطیل", "diamond": "لوزی", "ellipse": "بیضی", diff --git a/src/locales/fi-FI.json b/src/locales/fi-FI.json index 6859b288..01a1bb6c 100644 --- a/src/locales/fi-FI.json +++ b/src/locales/fi-FI.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Valinta", - "draw": "Vapaa piirto", + "freedraw": "Vapaa piirto", "rectangle": "Suorakulmio", "diamond": "Vinoneliö", "ellipse": "Soikio", diff --git a/src/locales/fr-FR.json b/src/locales/fr-FR.json index 87c9fa2d..2e04d58a 100644 --- a/src/locales/fr-FR.json +++ b/src/locales/fr-FR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Sélection", - "draw": "Dessin libre", + "freedraw": "Dessin libre", "rectangle": "Rectangle", "diamond": "Losange", "ellipse": "Ellipse", diff --git a/src/locales/he-IL.json b/src/locales/he-IL.json index 2cb92acc..48bb4623 100644 --- a/src/locales/he-IL.json +++ b/src/locales/he-IL.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "בחירה", - "draw": "ציור חופשי", + "freedraw": "ציור חופשי", "rectangle": "מרובע", "diamond": "מעוין", "ellipse": "אליפסה", diff --git a/src/locales/hi-IN.json b/src/locales/hi-IN.json index 95cfe6f4..f4febf09 100644 --- a/src/locales/hi-IN.json +++ b/src/locales/hi-IN.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "चयन", - "draw": "मुफ्त ड्रा", + "freedraw": "मुफ्त ड्रा", "rectangle": "आयात", "diamond": "तिर्यग्वर्ग", "ellipse": "दीर्घवृत्त", diff --git a/src/locales/hu-HU.json b/src/locales/hu-HU.json index 2842fea6..54b4e0af 100644 --- a/src/locales/hu-HU.json +++ b/src/locales/hu-HU.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Kijelölés", - "draw": "Szabadkézi rajz", + "freedraw": "Szabadkézi rajz", "rectangle": "Téglalap", "diamond": "Rombusz", "ellipse": "Ellipszis", diff --git a/src/locales/id-ID.json b/src/locales/id-ID.json index 02e20740..3dee867b 100644 --- a/src/locales/id-ID.json +++ b/src/locales/id-ID.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Pilihan", - "draw": "Menggambar bebas", + "freedraw": "Menggambar bebas", "rectangle": "Persegi", "diamond": "Berlian", "ellipse": "Elips", diff --git a/src/locales/it-IT.json b/src/locales/it-IT.json index 3270d6e7..ea60343f 100644 --- a/src/locales/it-IT.json +++ b/src/locales/it-IT.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Selezione", - "draw": "Disegno libero", + "freedraw": "Disegno libero", "rectangle": "Rettangolo", "diamond": "Rombo", "ellipse": "Ellisse", diff --git a/src/locales/ja-JP.json b/src/locales/ja-JP.json index acc5ed0b..9ddb36d6 100644 --- a/src/locales/ja-JP.json +++ b/src/locales/ja-JP.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "選択", - "draw": "手書き", + "freedraw": "手書き", "rectangle": "矩形", "diamond": "ひし形", "ellipse": "楕円", diff --git a/src/locales/kab-KAB.json b/src/locales/kab-KAB.json index d7785ce0..306541dc 100644 --- a/src/locales/kab-KAB.json +++ b/src/locales/kab-KAB.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Tafrayt", - "draw": "Unuɣ ilelli", + "freedraw": "Unuɣ ilelli", "rectangle": "Asrem", "diamond": "Ameɣṛun", "ellipse": "Taglayt", diff --git a/src/locales/ko-KR.json b/src/locales/ko-KR.json index 601f85ce..64bbcaa0 100644 --- a/src/locales/ko-KR.json +++ b/src/locales/ko-KR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "선택", - "draw": "자유롭게 그리기", + "freedraw": "자유롭게 그리기", "rectangle": "사각형", "diamond": "다이아몬드", "ellipse": "타원", diff --git a/src/locales/my-MM.json b/src/locales/my-MM.json index a4ff36d3..30dc0a51 100644 --- a/src/locales/my-MM.json +++ b/src/locales/my-MM.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "ရွေးချယ်", - "draw": "အလွတ်ရေးဆွဲ", + "freedraw": "အလွတ်ရေးဆွဲ", "rectangle": "စတုဂံ", "diamond": "စိန်", "ellipse": "အဝိုင်း", diff --git a/src/locales/nb-NO.json b/src/locales/nb-NO.json index f37c5ca9..276a2238 100644 --- a/src/locales/nb-NO.json +++ b/src/locales/nb-NO.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Velg", - "draw": "Frihåndstegning", + "freedraw": "Frihåndstegning", "rectangle": "Rektangel", "diamond": "Diamant", "ellipse": "Ellipse", diff --git a/src/locales/nl-NL.json b/src/locales/nl-NL.json index 34934542..478205e9 100644 --- a/src/locales/nl-NL.json +++ b/src/locales/nl-NL.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Selectie", - "draw": "Vrij tekenen", + "freedraw": "Vrij tekenen", "rectangle": "Rechthoek", "diamond": "Ruit", "ellipse": "Ovaal", diff --git a/src/locales/nn-NO.json b/src/locales/nn-NO.json index 5298d818..8336340b 100644 --- a/src/locales/nn-NO.json +++ b/src/locales/nn-NO.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Vel", - "draw": "Frihandsteikning", + "freedraw": "Frihandsteikning", "rectangle": "Rektangel", "diamond": "Diamant", "ellipse": "Ellipse", diff --git a/src/locales/oc-FR.json b/src/locales/oc-FR.json index 935fee55..caf9e25e 100644 --- a/src/locales/oc-FR.json +++ b/src/locales/oc-FR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Seleccion", - "draw": "Dessenh liure", + "freedraw": "Dessenh liure", "rectangle": "Rectangle", "diamond": "Lausange", "ellipse": "Ellipsa", diff --git a/src/locales/pa-IN.json b/src/locales/pa-IN.json index 020a54dc..31f4eba4 100644 --- a/src/locales/pa-IN.json +++ b/src/locales/pa-IN.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "ਚੋਣਕਾਰ", - "draw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ", + "freedraw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ", "rectangle": "ਆਇਤ", "diamond": "ਹੀਰਾ", "ellipse": "ਅੰਡਾਕਾਰ", diff --git a/src/locales/pl-PL.json b/src/locales/pl-PL.json index 21d4b9aa..e21e17f2 100644 --- a/src/locales/pl-PL.json +++ b/src/locales/pl-PL.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Zaznaczenie", - "draw": "Swobodne rysowanie", + "freedraw": "Swobodne rysowanie", "rectangle": "Prostokąt", "diamond": "Romb", "ellipse": "Elipsa", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index a5ad15d4..422900be 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Seleção", - "draw": "Desenho livre", + "freedraw": "Desenho livre", "rectangle": "Retângulo", "diamond": "Losango", "ellipse": "Elipse", diff --git a/src/locales/pt-PT.json b/src/locales/pt-PT.json index aebe4e4b..f95a6ba8 100644 --- a/src/locales/pt-PT.json +++ b/src/locales/pt-PT.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Seleção", - "draw": "Desenho livre", + "freedraw": "Desenho livre", "rectangle": "Retângulo", "diamond": "Losango", "ellipse": "Elipse", diff --git a/src/locales/ro-RO.json b/src/locales/ro-RO.json index a2cd0614..3b077028 100644 --- a/src/locales/ro-RO.json +++ b/src/locales/ro-RO.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Selecție", - "draw": "Desenare liberă", + "freedraw": "Desenare liberă", "rectangle": "Dreptunghi", "diamond": "Romb", "ellipse": "Elipsă", diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 756e8e57..6cbb1bfb 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Выделение области", - "draw": "Свободное рисование", + "freedraw": "Свободное рисование", "rectangle": "Прямоугольник", "diamond": "Ромб", "ellipse": "Эллипс", diff --git a/src/locales/sk-SK.json b/src/locales/sk-SK.json index d5248c84..93490dbb 100644 --- a/src/locales/sk-SK.json +++ b/src/locales/sk-SK.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Výber", - "draw": "Voľné kreslenie", + "freedraw": "Voľné kreslenie", "rectangle": "Obdĺžnik", "diamond": "Diamant", "ellipse": "Elipsa", diff --git a/src/locales/sv-SE.json b/src/locales/sv-SE.json index c546690a..f1731039 100644 --- a/src/locales/sv-SE.json +++ b/src/locales/sv-SE.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Markering", - "draw": "Frihand", + "freedraw": "Frihand", "rectangle": "Rektangel", "diamond": "Diamant", "ellipse": "Ellips", diff --git a/src/locales/tr-TR.json b/src/locales/tr-TR.json index 41ef9fc5..4166e568 100644 --- a/src/locales/tr-TR.json +++ b/src/locales/tr-TR.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Seçme", - "draw": "Serbest çizim", + "freedraw": "Serbest çizim", "rectangle": "Dikdörtgen", "diamond": "Elmas", "ellipse": "Elips", diff --git a/src/locales/uk-UA.json b/src/locales/uk-UA.json index 0c6cdbe9..32fde458 100644 --- a/src/locales/uk-UA.json +++ b/src/locales/uk-UA.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "Виділення", - "draw": "Вільне креслення", + "freedraw": "Вільне креслення", "rectangle": "Прямокутник", "diamond": "Ромб", "ellipse": "Еліпс", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index f5beeb80..718728e8 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "选择", - "draw": "自由书写", + "freedraw": "自由书写", "rectangle": "矩形", "diamond": "菱形", "ellipse": "椭圆", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 5b7db003..943cfd54 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -152,7 +152,7 @@ }, "toolBar": { "selection": "選取", - "draw": "繪圖", + "freedraw": "繪圖", "rectangle": "長方形", "diamond": "菱形", "ellipse": "橢圓", diff --git a/src/math.ts b/src/math.ts index 564be027..1f2ffc4f 100644 --- a/src/math.ts +++ b/src/math.ts @@ -249,6 +249,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { return false; }; +// TODO: Rounding this point causes some shake when free drawing export const getGridPoint = ( x: number, y: number, diff --git a/src/points.ts b/src/points.ts index f6f3c900..78062035 100644 --- a/src/points.ts +++ b/src/points.ts @@ -8,6 +8,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => { height: Math.max(...ys) - Math.min(...ys), }; }; + export const rescalePoints = ( dimension: 0 | 1, nextDimensionSize: number, diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 24070da9..cfcae09b 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -4,8 +4,13 @@ import { ExcalidrawTextElement, Arrowhead, NonDeletedExcalidrawElement, + ExcalidrawFreeDrawElement, } from "../element/types"; -import { isTextElement, isLinearElement } from "../element/typeChecks"; +import { + isTextElement, + isLinearElement, + isFreeDrawElement, +} from "../element/typeChecks"; import { getDiamondPoints, getElementAbsoluteCoords, @@ -27,14 +32,17 @@ import { isPathALoop } from "../math"; import rough from "roughjs/bin/rough"; import { Zoom } from "../types"; import { getDefaultAppState } from "../appState"; +import getFreeDrawShape from "perfect-freehand"; const defaultAppState = getDefaultAppState(); -const CANVAS_PADDING = 20; - const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; + const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth]; +const getCanvasPadding = (element: ExcalidrawElement) => + element.type === "freedraw" ? element.strokeWidth * 12 : 20; + export interface ExcalidrawElementWithCanvas { element: ExcalidrawElement | ExcalidrawTextElement; canvas: HTMLCanvasElement; @@ -49,18 +57,25 @@ const generateElementCanvas = ( ): ExcalidrawElementWithCanvas => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; + const padding = getCanvasPadding(element); let canvasOffsetX = 0; let canvasOffsetY = 0; - if (isLinearElement(element)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + if (isLinearElement(element) || isFreeDrawElement(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 = distance(x1, x2) * window.devicePixelRatio * zoom.value + - CANVAS_PADDING * zoom.value * 2; + padding * zoom.value * 2; canvas.height = distance(y1, y2) * window.devicePixelRatio * zoom.value + - CANVAS_PADDING * zoom.value * 2; + padding * zoom.value * 2; canvasOffsetX = element.x > x1 @@ -80,13 +95,13 @@ const generateElementCanvas = ( } else { canvas.width = element.width * window.devicePixelRatio * zoom.value + - CANVAS_PADDING * zoom.value * 2; + padding * zoom.value * 2; canvas.height = 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( window.devicePixelRatio * zoom.value, @@ -94,11 +109,10 @@ const generateElementCanvas = ( ); const rc = rough.canvas(canvas); + drawElementOnCanvas(element, rc, context); - context.translate( - -(CANVAS_PADDING * zoom.value), - -(CANVAS_PADDING * zoom.value), - ); + + context.translate(-(padding * zoom.value), -(padding * zoom.value)); context.scale( 1 / (window.devicePixelRatio * zoom.value), 1 / (window.devicePixelRatio * zoom.value), @@ -138,6 +152,19 @@ const drawElementOnCanvas = ( }); 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: { if (isTextElement(element)) { const rtl = isRTL(element.text); @@ -243,10 +270,8 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => { } return options; } - case "line": - case "draw": { - // If shape is a line and is a closed shape, - // fill the shape if a color is set. + case "draw": + case "line": { if (isPathALoop(element.points)) { options.fillStyle = element.fillStyle; options.fill = @@ -256,6 +281,7 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => { } return options; } + case "freedraw": case "arrow": return options; 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 = ( element: NonDeletedExcalidrawElement, generator: RoughGenerator, ) => { let shape = shapeCache.get(element) || null; + if (!shape) { elementWithCanvasCache.delete(element); @@ -327,8 +359,8 @@ const generateElementShape = ( generateRoughOptions(element), ); break; - case "line": case "draw": + case "line": case "arrow": { const options = generateRoughOptions(element); @@ -380,15 +412,18 @@ const generateElementShape = ( ...options, fill: element.strokeColor, fillStyle: "solid", + stroke: "none", }), ]; } // Arrow arrowheads const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; + if (element.strokeStyle === "dotted") { // 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 { // for solid/dashed, keep solid arrow cap delete options.strokeLineDash; @@ -423,6 +458,12 @@ const generateElementShape = ( shape.push(...shapes); } } + + break; + } + case "freedraw": { + generateFreeDrawShape(element); + shape = []; break; } case "text": { @@ -447,7 +488,9 @@ const generateElementWithCanvas = ( !sceneState?.shouldCacheIgnoreZoom; if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) { const elementWithCanvas = generateElementCanvas(element, zoom); + elementWithCanvasCache.set(element, elementWithCanvas); + return elementWithCanvas; } return prevElementWithCanvas; @@ -460,20 +503,29 @@ const drawElementFromCanvas = ( sceneState: SceneState, ) => { 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 cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio; context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); context.translate(cx, cy); context.rotate(element.angle); + context.drawImage( elementWithCanvas.canvas!, (-(x2 - x1) / 2) * window.devicePixelRatio - - (CANVAS_PADDING * elementWithCanvas.canvasZoom) / - elementWithCanvas.canvasZoom, + (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, (-(y2 - y1) / 2) * window.devicePixelRatio - - (CANVAS_PADDING * elementWithCanvas.canvasZoom) / - elementWithCanvas.canvasZoom, + (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, ); @@ -508,11 +560,37 @@ export const renderElement = ( ); 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 "diamond": case "ellipse": - case "line": case "draw": + case "line": case "arrow": case "text": { generateElementShape(element, generator); @@ -583,8 +661,8 @@ export const renderElementToSvg = ( svgRoot.appendChild(node); break; } - case "line": case "draw": + case "line": case "arrow": { generateElementShape(element, generator); const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); @@ -604,7 +682,7 @@ export const renderElementToSvg = ( }) rotate(${degree} ${cx} ${cy})`, ); if ( - (element.type === "line" || element.type === "draw") && + element.type === "line" && isPathALoop(element.points) && element.backgroundColor !== "transparent" ) { @@ -615,6 +693,28 @@ export const renderElementToSvg = ( svgRoot.appendChild(group); 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: { if (isTextElement(element)) { const opacity = element.opacity / 100; @@ -666,3 +766,55 @@ export const renderElementToSvg = ( } } }; + +export const pathsCache = new WeakMap([]); + +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(" "); +} diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index d96bedc9..45a8a2db 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -201,11 +201,12 @@ export const renderScene = ( renderGrid?: boolean; } = {}, ) => { - if (!canvas) { + if (canvas === null) { return { atLeastOneVisibleElement: false }; } const context = canvas.getContext("2d")!; + context.scale(scale, scale); // When doing calculations based on canvas width we should used normalized one diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index a615b81d..62e688e3 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -9,22 +9,25 @@ export const hasBackground = (type: string) => type === "rectangle" || type === "ellipse" || type === "diamond" || - type === "draw" || 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 === "ellipse" || type === "diamond" || type === "arrow" || - type === "draw" || type === "line"; export const canChangeSharpness = (type: string) => - type === "rectangle" || - type === "arrow" || - type === "draw" || - type === "line"; + type === "rectangle" || type === "arrow" || type === "line"; export const hasText = (type: string) => type === "text"; diff --git a/src/scene/index.ts b/src/scene/index.ts index 36cc4161..27e2596f 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -9,7 +9,8 @@ export { export { calculateScrollCenter } from "./scroll"; export { hasBackground, - hasStroke, + hasStrokeWidth, + hasStrokeStyle, canHaveArrowheads, canChangeSharpness, getElementAtPosition, diff --git a/src/shapes.tsx b/src/shapes.tsx index 0f3003c5..ec706872 100644 --- a/src/shapes.tsx +++ b/src/shapes.tsx @@ -80,7 +80,7 @@ export const SHAPES = [ > ), - value: "draw", + value: "freedraw", key: KEYS.X, }, { diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index dd64a7ec..651e5673 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -6280,7 +6280,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "elementLocked": false, - "elementType": "draw", + "elementType": "freedraw", "errorMessage": null, "exportBackground": true, "exportEmbedScene": false, @@ -6596,8 +6596,6 @@ Object { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -6614,18 +6612,26 @@ Object { 50, 10, ], + Array [ + 50, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 941653321, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 1402203177, + "type": "freedraw", + "version": 4, + "versionNonce": 1359939303, "width": 50, "x": 550, "y": -10, @@ -8246,8 +8252,6 @@ Object { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -8264,18 +8268,26 @@ Object { 50, 10, ], + Array [ + 50, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 941653321, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 1402203177, + "type": "freedraw", + "version": 4, + "versionNonce": 1359939303, "width": 50, "x": 550, "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 7 selects draw tool: [end of test] appState 1`] = ` +exports[`regression tests key 7 selects freedraw tool: [end of test] appState 1`] = ` Object { "collaborators": Map {}, "currentChartType": "bar", @@ -10379,7 +10391,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "elementLocked": false, - "elementType": "draw", + "elementType": "freedraw", "errorMessage": null, "exportBackground": true, "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 { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10457,25 +10467,33 @@ Object { 10, 10, ], + Array [ + 10, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 337897, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 449462985, + "type": "freedraw", + "version": 4, + "versionNonce": 453191, "width": 10, "x": 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 { "recording": false, "redoStack": Array [], @@ -10505,8 +10523,6 @@ Object { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -10523,18 +10539,26 @@ Object { 10, 10, ], + Array [ + 10, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 337897, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 449462985, + "type": "freedraw", + "version": 4, + "versionNonce": 453191, "width": 10, "x": 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`] = ` 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 x selects draw tool: [end of test] appState 1`] = ` +exports[`regression tests key x selects freedraw tool: [end of test] appState 1`] = ` Object { "collaborators": Map {}, "currentChartType": "bar", @@ -11453,7 +11477,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "elementLocked": false, - "elementType": "draw", + "elementType": "freedraw", "errorMessage": null, "exportBackground": true, "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 { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11531,25 +11553,33 @@ Object { 10, 10, ], + Array [ + 10, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 337897, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 449462985, + "type": "freedraw", + "version": 4, + "versionNonce": 453191, "width": 10, "x": 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 { "recording": false, "redoStack": Array [], @@ -11579,8 +11609,6 @@ Object { "angle": 0, "backgroundColor": "transparent", "boundElementIds": null, - "endArrowhead": null, - "endBinding": null, "fillStyle": "hachure", "groupIds": Array [], "height": 10, @@ -11597,18 +11625,26 @@ Object { 10, 10, ], + Array [ + 10, + 10, + ], + ], + "pressures": Array [ + 0, + 0, + 0, ], "roughness": 1, "seed": 337897, - "startArrowhead": null, - "startBinding": null, + "simulatePressure": false, "strokeColor": "#000000", "strokeSharpness": "round", "strokeStyle": "solid", "strokeWidth": 1, - "type": "draw", - "version": 3, - "versionNonce": 449462985, + "type": "freedraw", + "version": 4, + "versionNonce": 453191, "width": 10, "x": 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`] = ` Object { diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index b2d0cad4..3cbb487c 100644 --- a/src/tests/flip.test.tsx +++ b/src/tests/flip.test.tsx @@ -71,7 +71,7 @@ const createAndSelectOneLine = (angle: number = 0) => { }; const createAndReturnOneDraw = (angle: number = 0) => { - return UI.createElement("draw", { + return UI.createElement("freedraw", { x: 0, y: 0, width: 50, diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 37e9f08c..57b1238b 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -3,6 +3,7 @@ import { ExcalidrawGenericElement, ExcalidrawTextElement, ExcalidrawLinearElement, + ExcalidrawFreeDrawElement, } from "../../element/types"; import { newElement, newTextElement, newLinearElement } from "../../element"; import { DEFAULT_VERTICAL_ALIGN } from "../../constants"; @@ -12,6 +13,7 @@ import fs from "fs"; import util from "util"; import path from "path"; import { getMimeType } from "../../data/blob"; +import { newFreeDrawElement } from "../../element/newElement"; const readFile = util.promisify(fs.readFile); @@ -81,8 +83,10 @@ export class API { verticalAlign?: T extends "text" ? ExcalidrawTextElement["verticalAlign"] : never; - }): T extends "arrow" | "line" | "draw" + }): T extends "arrow" | "line" ? ExcalidrawLinearElement + : T extends "freedraw" + ? ExcalidrawFreeDrawElement : T extends "text" ? ExcalidrawTextElement : ExcalidrawGenericElement => { @@ -125,11 +129,17 @@ export class API { verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, }); break; + case "freedraw": + element = newFreeDrawElement({ + type: type as "freedraw", + simulatePressure: true, + ...base, + }); + break; case "arrow": case "line": - case "draw": element = newLinearElement({ - type: type as "arrow" | "line" | "draw", + type: type as "arrow" | "line", startArrowhead: null, endArrowhead: null, ...base, diff --git a/src/tests/helpers/ui.ts b/src/tests/helpers/ui.ts index 5bcb3ddd..26dbc24a 100644 --- a/src/tests/helpers/ui.ts +++ b/src/tests/helpers/ui.ts @@ -213,14 +213,14 @@ export class UI { height?: number; angle?: number; } = {}, - ): (T extends "arrow" | "line" | "draw" + ): (T extends "arrow" | "line" | "freedraw" ? ExcalidrawLinearElement : T extends "text" ? ExcalidrawTextElement : ExcalidrawElement) & { /** Returns the actual, current element from the elements array, instead of the proxy */ - get(): T extends "arrow" | "line" | "draw" + get(): T extends "arrow" | "line" | "freedraw" ? ExcalidrawLinearElement : T extends "text" ? ExcalidrawTextElement diff --git a/src/tests/queries/toolQueries.ts b/src/tests/queries/toolQueries.ts index 6c18045a..1d680af6 100644 --- a/src/tests/queries/toolQueries.ts +++ b/src/tests/queries/toolQueries.ts @@ -7,7 +7,7 @@ const toolMap = { ellipse: "ellipse", arrow: "arrow", line: "line", - draw: "draw", + freedraw: "freedraw", text: "text", }; diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 7af86005..51f0100a 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -106,7 +106,7 @@ describe("regression tests", () => { mouse.click(30, 10); Keyboard.keyPress(KEYS.ENTER); - UI.clickTool("draw"); + UI.clickTool("freedraw"); mouse.down(40, -20); mouse.up(50, 10); @@ -118,7 +118,7 @@ describe("regression tests", () => { "line", "arrow", "line", - "draw", + "freedraw", ]); }); @@ -146,7 +146,7 @@ describe("regression tests", () => { [`4${KEYS.E}`, "ellipse", true], [`5${KEYS.A}`, "arrow", true], [`6${KEYS.L}`, "line", true], - [`7${KEYS.X}`, "draw", false], + [`7${KEYS.X}`, "freedraw", false], ] as [string, ExcalidrawElement["type"], boolean][]) { for (const key of keys) { it(`key ${key} selects ${shape} tool`, () => { diff --git a/yarn.lock b/yarn.lock index 16088904..26f0b34a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9249,6 +9249,11 @@ pepjs@0.5.3: version "0.5.3" 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: version "2.1.0" resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"