diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 4ff50335ef..8b0ef37ed3 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -405,8 +405,9 @@ export const ROUGHNESS = { export const STROKE_WIDTH = { thin: 1, - bold: 2, - extraBold: 4, + medium: 2, + bold: 4, + extraBold: 8, } as const; export const DEFAULT_ELEMENT_PROPS: { @@ -429,6 +430,8 @@ export const DEFAULT_ELEMENT_PROPS: { locked: false, }; +export const DEFAULT_FREE_DRAW_STROKE_SHAPE = "variable" as const; + export const LIBRARY_SIDEBAR_TAB = "library"; export const CANVAS_SEARCH_TAB = "search"; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a072b81a90..fcb3830ee2 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -168,12 +168,14 @@ export class ElementBounds { ), ), ); + const padding = + element.strokeShape === "fixed" ? element.strokeWidth / 2 : 0; return [ - minX + element.x, - minY + element.y, - maxX + element.x, - maxY + element.y, + minX + element.x - padding, + minY + element.y - padding, + maxX + element.x + padding, + maxY + element.y + padding, ]; } else if (isLinearElement(element)) { bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); @@ -703,10 +705,11 @@ const getFreeDrawElementAbsoluteCoords = ( element: ExcalidrawFreeDrawElement, ): [number, number, number, number, number, number] => { const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points); - const x1 = minX + element.x; - const y1 = minY + element.y; - const x2 = maxX + element.x; - const y2 = maxY + element.y; + const padding = element.strokeShape === "fixed" ? element.strokeWidth / 2 : 0; + const x1 = minX + element.x - padding; + const y1 = minY + element.y - padding; + const x2 = maxX + element.x + padding; + const y2 = maxY + element.y + padding; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; }; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index c260ae5267..fbae3ebb66 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -27,6 +27,7 @@ import type { import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; +import { isLoopFreeDrawElement } from "./freedraw"; import { isPathALoop } from "./utils"; import { doBoundsIntersect, @@ -93,7 +94,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => { } if (element.type === "freedraw") { - return isDraggableFromInside && isPathALoop(element.points); + return isDraggableFromInside && isLoopFreeDrawElement(element); } return isDraggableFromInside || isImageElement(element); @@ -754,8 +755,8 @@ export const isPointInElement = ( elementsMap: ElementsMap, ) => { if ( - (isLinearElement(element) || isFreeDrawElement(element)) && - !isPathALoop(element.points) + (isLinearElement(element) && !isPathALoop(element.points)) || + (isFreeDrawElement(element) && !isLoopFreeDrawElement(element)) ) { // There isn't any "inside" for a non-looping path return false; diff --git a/packages/element/src/freedraw.ts b/packages/element/src/freedraw.ts new file mode 100644 index 0000000000..baad44559d --- /dev/null +++ b/packages/element/src/freedraw.ts @@ -0,0 +1,181 @@ +import { + pointDistance, + vectorCross, + vectorDot, + vectorFromPoint, +} from "@excalidraw/math"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { isPathALoop } from "./utils"; + +import type { ExcalidrawFreeDrawElement } from "./types"; + +type ZoomValue = NonNullable[1]>; + +type FixedFreeDrawSimplificationProfile = { + minPointDistancePx: number; + maxPointDistance: number; + strokeDistanceFactor: number; + collinearityFactor: number; + minAlignment: number; + zoomScaling: "sqrt" | "none"; +}; + +const FIXED_FREEDRAW_CAPTURE_PROFILE: FixedFreeDrawSimplificationProfile = { + minPointDistancePx: 0.35, + maxPointDistance: 0.85, + strokeDistanceFactor: 0.08, + collinearityFactor: 0.3, + minAlignment: 0.985, + zoomScaling: "sqrt", +}; + +const hasSyntheticLoopClosure = ( + points: readonly LocalPoint[], +): points is readonly [LocalPoint, LocalPoint, ...LocalPoint[]] => { + if (points.length < 3) { + return false; + } + + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + + return firstPoint[0] === lastPoint[0] && firstPoint[1] === lastPoint[1]; +}; + +const stripSyntheticLoopClosure = (points: readonly LocalPoint[]) => + hasSyntheticLoopClosure(points) ? points.slice(0, -1) : points; + +export const isLoopFreeDrawElement = ( + element: ExcalidrawFreeDrawElement, + zoomValue: ZoomValue = 1 as ZoomValue, +) => element.strokeShape !== "fixed" && isPathALoop(element.points, zoomValue); + +export const getFixedFreeDrawPoints = ( + element: ExcalidrawFreeDrawElement, +): readonly LocalPoint[] => stripSyntheticLoopClosure(element.points); + +export const getFixedFreeDrawPointSamplingDistance = ( + strokeWidth: number, + zoomValue: ZoomValue = 1 as ZoomValue, + profile: FixedFreeDrawSimplificationProfile = FIXED_FREEDRAW_CAPTURE_PROFILE, +) => + Math.min( + Math.max( + profile.minPointDistancePx / + (profile.zoomScaling === "sqrt" + ? Math.max(1, Math.sqrt(zoomValue)) + : 1), + strokeWidth * profile.strokeDistanceFactor, + ), + profile.maxPointDistance, + ); + +const isRedundantFixedFreeDrawPoint = ( + previousPoint: LocalPoint, + currentPoint: LocalPoint, + nextPoint: LocalPoint, + strokeWidth: number, + zoomValue: ZoomValue, + profile: FixedFreeDrawSimplificationProfile, +) => { + const previousSegmentLength = pointDistance(previousPoint, currentPoint); + const nextSegmentLength = pointDistance(currentPoint, nextPoint); + + if (!previousSegmentLength || !nextSegmentLength) { + return true; + } + + const previousVector = vectorFromPoint(currentPoint, previousPoint); + const nextVector = vectorFromPoint(nextPoint, currentPoint); + const alignment = + vectorDot(previousVector, nextVector) / + (previousSegmentLength * nextSegmentLength); + + if (alignment < profile.minAlignment) { + return false; + } + + const chord = vectorFromPoint(nextPoint, previousPoint); + const chordLength = pointDistance(previousPoint, nextPoint); + + if (!chordLength) { + return true; + } + + const distanceToChord = + Math.abs(vectorCross(vectorFromPoint(currentPoint, previousPoint), chord)) / + chordLength; + + return ( + distanceToChord <= + getFixedFreeDrawPointSamplingDistance(strokeWidth, zoomValue, profile) * + profile.collinearityFactor + ); +}; + +export const getFixedFreeDrawPointAction = ({ + points, + nextPoint, + strokeWidth, + zoomValue, + isFinalPoint = false, + profile, +}: { + points: readonly LocalPoint[]; + nextPoint: LocalPoint; + strokeWidth: number; + zoomValue: ZoomValue; + isFinalPoint?: boolean; + profile?: FixedFreeDrawSimplificationProfile; +}) => { + const simplificationProfile = profile ?? FIXED_FREEDRAW_CAPTURE_PROFILE; + const lastPoint = points[points.length - 1]; + + if (!lastPoint) { + return "append" as const; + } + + if (lastPoint[0] === nextPoint[0] && lastPoint[1] === nextPoint[1]) { + return "discard" as const; + } + + const samplingDistance = getFixedFreeDrawPointSamplingDistance( + strokeWidth, + zoomValue, + simplificationProfile, + ); + + if (points.length === 1) { + return !isFinalPoint && + pointDistance(lastPoint, nextPoint) < samplingDistance + ? ("discard" as const) + : ("append" as const); + } + + const previousPoint = points[points.length - 2]; + + if ( + isRedundantFixedFreeDrawPoint( + previousPoint, + lastPoint, + nextPoint, + strokeWidth, + zoomValue, + simplificationProfile, + ) + ) { + return "replace" as const; + } + + if (!isFinalPoint && pointDistance(lastPoint, nextPoint) < samplingDistance) { + return "discard" as const; + } + + return "append" as const; +}; + +export const getRenderableFixedFreeDrawPoints = ( + element: ExcalidrawFreeDrawElement, +): readonly LocalPoint[] => getFixedFreeDrawPoints(element); diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index c55537d451..4752187dda 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -69,6 +69,7 @@ export * from "./duplicate"; export * from "./elbowArrow"; export * from "./elementLink"; export * from "./embeddable"; +export * from "./freedraw"; export * from "./flowchart"; export * from "./arrows/focus"; export * from "./fractionalIndex"; diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index eb6350c2bf..c24e3edc66 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -1,4 +1,5 @@ import { + DEFAULT_FREE_DRAW_STROKE_SHAPE, getSizeFromPoints, randomInteger, getUpdatedTimestamp, @@ -18,6 +19,8 @@ import type { ElementsMap, ExcalidrawElbowArrowElement, ExcalidrawElement, + ExcalidrawFreeDrawElement, + FreeDrawStrokeShape, NonDeletedSceneElementsMap, } from "./types"; @@ -177,6 +180,30 @@ export const newElementWith = ( }; }; +export const newFreeDrawElementWithStrokeShape = < + TElement extends ExcalidrawFreeDrawElement, +>( + element: TElement, + strokeShape: FreeDrawStrokeShape, +): TElement => { + if (strokeShape === DEFAULT_FREE_DRAW_STROKE_SHAPE) { + if (!("strokeShape" in element)) { + return element; + } + const nextElement = newElementWith( + element, + {} as ElementUpdate, + true, + ); + delete (nextElement as Mutable>).strokeShape; + return nextElement; + } + + return newElementWith(element, { + strokeShape, + } as ElementUpdate); +}; + /** * Mutates element, bumping `version`, `versionNonce`, and `updated`. * diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index ec50a81ff2..54c86d5841 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -445,6 +445,7 @@ export const newFreeDrawElement = ( points?: ExcalidrawFreeDrawElement["points"]; simulatePressure: boolean; pressures?: ExcalidrawFreeDrawElement["pressures"]; + strokeShape?: ExcalidrawFreeDrawElement["strokeShape"]; } & ElementConstructorOpts, ): NonDeleted => { return { @@ -452,6 +453,7 @@ export const newFreeDrawElement = ( points: opts.points || [], pressures: opts.pressures || [], simulatePressure: opts.simulatePressure, + ...(opts.strokeShape === "fixed" ? { strokeShape: opts.strokeShape } : {}), }; }; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 96bff999bc..e9602f0d04 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -417,16 +417,27 @@ const drawElementOnCanvas = ( case "freedraw": { // Draw directly to canvas context.save(); + context.lineJoin = "round"; + context.lineCap = "round"; const shapes = ShapeCache.generateElementShape(element, renderConfig); + const isFixedStroke = element.strokeShape === "fixed"; for (const shape of shapes) { if (typeof shape === "string") { - context.fillStyle = + const strokeColor = renderConfig.theme === THEME.DARK ? applyDarkModeFilter(element.strokeColor) : element.strokeColor; - context.fill(new Path2D(shape)); + + if (isFixedStroke) { + context.strokeStyle = strokeColor; + context.lineWidth = element.strokeWidth; + context.stroke(new Path2D(shape)); + } else { + context.fillStyle = strokeColor; + context.fill(new Path2D(shape)); + } } else { rc.draw(shape); } diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 158ace7519..519c90dd3e 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -1,4 +1,5 @@ import { simplify } from "points-on-curve"; +import { DEFAULT_FREE_DRAW_STROKE_SHAPE } from "@excalidraw/common"; import { getStroke } from "perfect-freehand"; import { @@ -13,6 +14,7 @@ import { import { pointFrom, pointDistance, + round, type LocalPoint, pointRotateRads, } from "@excalidraw/math"; @@ -52,6 +54,10 @@ import { isIframeLikeElement, isLinearElement, } from "./typeChecks"; +import { + getRenderableFixedFreeDrawPoints, + isLoopFreeDrawElement, +} from "./freedraw"; import { getCornerRadius, isPathALoop } from "./utils"; import { headingForPointIsHorizontal } from "./heading"; @@ -244,7 +250,12 @@ export const generateRoughOptions = ( } case "line": case "freedraw": { - if (isPathALoop(element.points)) { + const isLoop = + element.type === "freedraw" + ? isLoopFreeDrawElement(element) + : isPathALoop(element.points); + + if (isLoop) { options.fillStyle = element.fillStyle; options.fill = element.backgroundColor === "transparent" @@ -966,7 +977,7 @@ const _generateElementShape = ( const shapes: ElementShapes[typeof element.type] = []; // (1) background fill (rc shape), optional - if (isPathALoop(element.points)) { + if (isLoopFreeDrawElement(element)) { // generate rough polygon to fill freedraw shape const simplifiedPoints = simplify( element.points as Mutable, @@ -1173,6 +1184,12 @@ export const toggleLinePolygonState = ( // NOTE not cached (-> for SVG export) const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => { + if (element.strokeShape === "fixed") { + return getSvgPathFromFixedFreeDrawPoints( + getRenderableFixedFreeDrawPoints(element), + ) as SVGPathString; + } + return getSvgPathFromStroke( getFreedrawOutlinePoints(element), ) as SVGPathString; @@ -1181,34 +1198,127 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => { export const getFreedrawOutlinePoints = ( element: ExcalidrawFreeDrawElement, ) => { + const strokeShape = element.strokeShape ?? DEFAULT_FREE_DRAW_STROKE_SHAPE; + const isFixedStroke = strokeShape === "fixed"; // If input points are empty (should they ever be?) return a dot - const inputPoints = element.simulatePressure + const inputPoints = isFixedStroke + ? element.points.length + ? element.points + : [[0, 0]] + : element.simulatePressure ? element.points : element.points.length ? element.points.map(([x, y], i) => [x, y, element.pressures[i]]) : [[0, 0, 0.5]]; return getStroke(inputPoints as number[][], { - simulatePressure: element.simulatePressure, - size: element.strokeWidth * 4.25, - thinning: 0.6, + simulatePressure: isFixedStroke ? false : element.simulatePressure, + size: isFixedStroke ? element.strokeWidth : element.strokeWidth * 4.25, + thinning: isFixedStroke ? 0 : 0.6, smoothing: 0.5, streamline: 0.5, - easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine + easing: isFixedStroke ? (t) => t : (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine last: true, }) as [number, number][]; }; -const med = (A: number[], B: number[]) => { +const med = (A: readonly number[], B: readonly number[]) => { return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; }; +const roundPoint = (point: readonly number[]) => + `${round(point[0], 2)},${round(point[1], 2)} `; + +const averagePoint = (A: readonly number[], B: readonly number[]) => + `${round((A[0] + B[0]) / 2, 2)},${round((A[1] + B[1]) / 2, 2)} `; + // Trim SVG path data so number are each two decimal points. This // improves SVG exports, and prevents rendering errors on points // with long decimals. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g; -const getSvgPathFromStroke = (points: number[][]): string => { +const getSvgPathFromPoints = ( + points: ReadonlyArray, + closed = false, +): string => { + const len = points.length; + + if (len < 2) { + return ""; + } + + const path = points + .slice(1) + .map((point) => `L${roundPoint(point)}`) + .join(""); + + return `M${roundPoint(points[0])}${path}${closed ? "Z" : ""}`; +}; + +const FIXED_FREEDRAW_MIN_SMOOTH_ALIGNMENT = 0.6; +const FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH = 0.2; + +const shouldSmoothFixedFreeDrawPoint = ( + previousPoint: readonly number[], + currentPoint: readonly number[], + nextPoint: readonly number[], +) => { + const previousDeltaX = currentPoint[0] - previousPoint[0]; + const previousDeltaY = currentPoint[1] - previousPoint[1]; + const nextDeltaX = nextPoint[0] - currentPoint[0]; + const nextDeltaY = nextPoint[1] - currentPoint[1]; + const previousSegmentLength = Math.hypot(previousDeltaX, previousDeltaY); + const nextSegmentLength = Math.hypot(nextDeltaX, nextDeltaY); + + if ( + previousSegmentLength < FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH || + nextSegmentLength < FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH + ) { + return false; + } + + const alignment = + (previousDeltaX * nextDeltaX + previousDeltaY * nextDeltaY) / + (previousSegmentLength * nextSegmentLength); + + return alignment >= FIXED_FREEDRAW_MIN_SMOOTH_ALIGNMENT; +}; + +const getSvgPathFromFixedFreeDrawPoints = ( + points: ReadonlyArray, +): string => { + const len = points.length; + + if (len < 2) { + return ""; + } + + if (len === 2) { + return `M${roundPoint(points[0])}L${roundPoint(points[1])}`; + } + + let path = `M${roundPoint(points[0])}`; + + for (let index = 1; index < len - 1; index++) { + const previousPoint = points[index - 1]; + const currentPoint = points[index]; + const nextPoint = points[index + 1]; + + path += shouldSmoothFixedFreeDrawPoint( + previousPoint, + currentPoint, + nextPoint, + ) + ? `Q${roundPoint(currentPoint)}${averagePoint(currentPoint, nextPoint)}` + : `L${roundPoint(currentPoint)}`; + } + + return `${path}L${roundPoint(points[len - 1])}`; +}; + +const getSvgPathFromStroke = ( + points: ReadonlyArray, +): string => { if (!points.length) { return ""; } diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 8d280f3141..644374871b 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -26,6 +26,7 @@ export type PointerType = "mouse" | "pen" | "touch"; export type StrokeRoundness = "round" | "sharp"; export type RoundnessType = ValueOf; export type StrokeStyle = "solid" | "dashed" | "dotted"; +export type FreeDrawStrokeShape = "variable" | "fixed"; export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN]; type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; @@ -390,6 +391,7 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & points: readonly LocalPoint[]; pressures: readonly number[]; simulatePressure: boolean; + strokeShape?: FreeDrawStrokeShape; }>; export type FileId = string & { _brand: "FileId" }; diff --git a/packages/element/tests/bounds.test.ts b/packages/element/tests/bounds.test.ts index 1c7cd3cd28..b67d56bc20 100644 --- a/packages/element/tests/bounds.test.ts +++ b/packages/element/tests/bounds.test.ts @@ -6,7 +6,11 @@ import type { LocalPoint } from "@excalidraw/math"; import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds"; -import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types"; +import type { + ExcalidrawElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, +} from "../src/types"; const _ce = ({ x, @@ -117,6 +121,31 @@ describe("getElementBounds", () => { expect(y2).toEqual(42.90569415042095); }); + it("fixed freedraw", () => { + const element = { + ..._ce({ + x: 40, + y: 30, + w: 10, + h: 0, + a: 0, + t: "freedraw", + }), + strokeWidth: 8, + points: [pointFrom(0, 0), pointFrom(10, 0)], + pressures: [], + simulatePressure: true, + strokeShape: "fixed", + } as unknown as ExcalidrawFreeDrawElement; + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + + expect(x1).toEqual(36); + expect(y1).toEqual(26); + expect(x2).toEqual(54); + expect(y2).toEqual(34); + }); + it("curved line", () => { const element = { ..._ce({ diff --git a/packages/element/tests/shape.test.ts b/packages/element/tests/shape.test.ts new file mode 100644 index 0000000000..cfb2699eeb --- /dev/null +++ b/packages/element/tests/shape.test.ts @@ -0,0 +1,120 @@ +import { pointFrom } from "@excalidraw/math"; +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; +import { vi } from "vitest"; + +vi.mock("perfect-freehand", () => ({ + getStroke: vi.fn(() => []), +})); + +import { getStroke } from "perfect-freehand"; + +import { getFreedrawOutlinePoints, ShapeCache } from "../src/shape"; + +describe("freedraw stroke shape", () => { + beforeEach(() => { + vi.mocked(getStroke).mockClear(); + ShapeCache.destroy(); + }); + + it("renders fixed strokes from the centerline path", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)], + }); + + const shapes = ShapeCache.generateElementShape(element, null); + + expect(shapes).toEqual([expect.any(String)]); + expect(shapes[0]).not.toContain("Z"); + expect(vi.mocked(getStroke)).not.toHaveBeenCalled(); + }); + + it("renders fixed stroke corners as straight segments", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(10, 10)], + }); + + const [path] = ShapeCache.generateElementShape(element, null); + + expect(path).toBe("M0,0 L10,0 L10,10 "); + }); + + it("smooths dense fixed stroke points without dropping them", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + points: [ + pointFrom(0, 0), + pointFrom(0.3, 0.1), + pointFrom(0.6, 0.35), + pointFrom(1, 0.9), + ], + }); + + const [path] = ShapeCache.generateElementShape(element, null); + + expect(path).toContain("Q0.3,0.1 "); + expect(path).toContain("Q0.6,0.35 "); + }); + + it("drops synthetic loop closure for fixed strokes", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(0, 0)], + }); + + const [path] = ShapeCache.generateElementShape(element, null); + + expect(path).toBe("M0,0 L10,0 "); + }); + + it("uses fixed perfect-freehand settings for fixed strokes", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + strokeWidth: 8, + points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)], + }); + + getFreedrawOutlinePoints(element); + + expect(vi.mocked(getStroke)).toHaveBeenCalledWith( + element.points, + expect.objectContaining({ + simulatePressure: false, + size: element.strokeWidth, + thinning: 0, + }), + ); + }); + + it("keeps pressure-aware perfect-freehand settings for variable strokes", () => { + const element = API.createElement({ + type: "freedraw", + points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)], + }); + + getFreedrawOutlinePoints({ + ...element, + simulatePressure: false, + pressures: [0.2, 0.8, 0.4], + }); + + expect(vi.mocked(getStroke)).toHaveBeenCalledWith( + [ + [0, 0, 0.2], + [10, 10, 0.8], + [20, 15, 0.4], + ], + expect.objectContaining({ + simulatePressure: false, + size: element.strokeWidth * 4.25, + thinning: 0.6, + }), + ); + }); +}); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 37332c4124..99a1a4d4fc 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -2,6 +2,7 @@ import { pointFrom } from "@excalidraw/math"; import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import { + isLoopFreeDrawElement, isValidPolygon, LinearElementEditor, newElementWith, @@ -242,7 +243,11 @@ export const actionFinalize = register({ // If the multi point line closes the loop, // set the last point to first point. // This ensures that loop remains closed at different scales. - const isLoop = isPathALoop(element.points, appState.zoom.value); + const isLoop = isLineElement(element) + ? isPathALoop(element.points, appState.zoom.value) + : isFreeDrawElement(element) + ? isLoopFreeDrawElement(element, appState.zoom.value) + : false; if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) { const linePoints = element.points; diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx index 38419ce827..1cd4f4bac3 100644 --- a/packages/excalidraw/actions/actionProperties.test.tsx +++ b/packages/excalidraw/actions/actionProperties.test.tsx @@ -1,4 +1,5 @@ import { queryByTestId } from "@testing-library/react"; +import { pointFrom } from "@excalidraw/math"; import { COLOR_PALETTE, @@ -10,7 +11,7 @@ import { import { Excalidraw } from "../index"; import { API } from "../tests/helpers/api"; import { UI } from "../tests/helpers/ui"; -import { render } from "../tests/test-utils"; +import { act, render } from "../tests/test-utils"; describe("element locking", () => { beforeEach(async () => { @@ -80,6 +81,20 @@ describe("element locking", () => { const centerTextAlign = queryByTestId(document.body, `align-right`); expect(centerTextAlign).toBeChecked(); }); + + it("should show the active freedraw stroke type", () => { + UI.clickTool("freedraw"); + + API.setAppState({ + currentItemStrokeShape: "fixed", + }); + + const fixedStrokeShape = queryByTestId( + document.body, + "strokeShape-fixed", + ); + expect(fixedStrokeShape).toBeChecked(); + }); }); describe("properties when elements selected", () => { @@ -144,6 +159,9 @@ describe("element locking", () => { expect( queryByTestId(document.body, `strokeWidth-thin`), ).not.toBeChecked(); + expect( + queryByTestId(document.body, `strokeWidth-medium`), + ).not.toBeChecked(); expect( queryByTestId(document.body, `strokeWidth-bold`), ).not.toBeChecked(); @@ -159,6 +177,7 @@ describe("element locking", () => { }); const text = API.createElement({ type: "text", + strokeWidth: STROKE_WIDTH.bold, fontFamily: FONT_FAMILY["Comic Shanns"], }); API.setElements([rect, text]); @@ -169,5 +188,35 @@ describe("element locking", () => { "active", ); }); + + it("should highlight the fixed freedraw stroke type for selected elements", () => { + const freedraw = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)], + }); + API.setElements([freedraw]); + API.setSelectedElements([freedraw]); + + const fixedStrokeShape = queryByTestId( + document.body, + "strokeShape-fixed", + ); + expect(fixedStrokeShape).toBeChecked(); + }); + + it("should apply fixed freedraw stroke type to newly drawn strokes", () => { + UI.clickTool("freedraw"); + + act(() => { + queryByTestId(document.body, "strokeShape-fixed")?.click(); + }); + + const freedraw = UI.createElement("freedraw", { + points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)], + }); + + expect(freedraw.strokeShape).toBe("fixed"); + }); }); }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index a046db6ea8..022ffca7e8 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -3,6 +3,7 @@ import { pointFrom } from "@excalidraw/math"; import { useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_FREE_DRAW_STROKE_SHAPE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_STROKE_COLOR_PALETTE, @@ -36,6 +37,7 @@ import { import { LinearElementEditor } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element"; +import { newFreeDrawElementWithStrokeShape } from "@excalidraw/element"; import { getArrowheadForPicker } from "@excalidraw/element"; import { @@ -47,6 +49,7 @@ import { isArrowElement, isBoundToContainer, isElbowArrow, + isFreeDrawElement, isLinearElement, isLineElement, isTextElement, @@ -70,6 +73,7 @@ import type { ElementsMap, ExcalidrawBindableElement, ExcalidrawElement, + ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, FontFamilyValues, @@ -105,8 +109,11 @@ import { SloppinessArtistIcon, SloppinessCartoonistIcon, StrokeWidthBaseIcon, + StrokeWidthMediumIcon, StrokeWidthBoldIcon, StrokeWidthExtraBoldIcon, + StrokeShapeFixedIcon, + StrokeShapeVariableIcon, FontSizeSmallIcon, FontSizeMediumIcon, FontSizeLargeIcon, @@ -187,6 +194,29 @@ export const changeProperty = ( }); }; +const getFreeDrawStrokeShape = (item: { + strokeShape?: ExcalidrawFreeDrawElement["strokeShape"]; + currentItemStrokeShape?: AppState["currentItemStrokeShape"]; +}) => + item.strokeShape ?? + item.currentItemStrokeShape ?? + DEFAULT_FREE_DRAW_STROKE_SHAPE; + +const getAppStateWithCurrentItemStrokeShape = ( + appState: AppState, + strokeShape: NonNullable, +) => { + // if (strokeShape === DEFAULT_FREE_DRAW_STROKE_SHAPE) { + // const { currentItemStrokeShape, ...nextAppState } = appState; + // return nextAppState; + // } + + return { + ...appState, + currentItemStrokeShape: strokeShape, + }; +}; + export const getFormValue = function ( elements: readonly ExcalidrawElement[], app: AppClassProperties, @@ -574,6 +604,12 @@ export const actionChangeStrokeWidth = register< icon: StrokeWidthBaseIcon, testId: "strokeWidth-thin", }, + { + value: STROKE_WIDTH.medium, + text: t("labels.medium"), + icon: StrokeWidthMediumIcon, + testId: "strokeWidth-medium", + }, { value: STROKE_WIDTH.bold, text: t("labels.bold"), @@ -602,6 +638,60 @@ export const actionChangeStrokeWidth = register< ), }); +export const actionChangeStrokeShape = register< + NonNullable +>({ + name: "changeStrokeShape", + label: "labels.strokeShape", + trackEvent: false, + perform: (elements, appState, value) => { + invariant(value, "actionChangeStrokeShape: value must be defined"); + return { + elements: changeProperty(elements, appState, (el) => + isFreeDrawElement(el) + ? newFreeDrawElementWithStrokeShape(el, value) + : el, + ), + appState: getAppStateWithCurrentItemStrokeShape(appState, value), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( +
+ {t("labels.strokeShape")} +
+ + getFreeDrawStrokeShape(element as ExcalidrawFreeDrawElement), + isFreeDrawElement, + (hasSelection) => + hasSelection ? null : getFreeDrawStrokeShape(appState), + )} + onChange={(value) => updateData(value)} + /> +
+
+ ), +}); + export const actionChangeSloppiness = register({ name: "changeSloppiness", label: "labels.sloppiness", diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index aa8bf41fc6..a1fa8188ef 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -1,4 +1,5 @@ import { + DEFAULT_FREE_DRAW_STROKE_SHAPE, DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, @@ -10,6 +11,7 @@ import { import { newElementWith } from "@excalidraw/element"; import { + isFreeDrawElement, hasBoundTextElement, canApplyRoundnessTypeToElement, getDefaultRoundnessTypeForElement, @@ -18,6 +20,7 @@ import { isExcalidrawElement, isTextElement, } from "@excalidraw/element"; +import { newFreeDrawElementWithStrokeShape } from "@excalidraw/element"; import { getBoundTextElement, @@ -154,6 +157,17 @@ export const actionPasteStyles = register({ }); } + if ( + isFreeDrawElement(newElement) && + isFreeDrawElement(elementStylesToCopyFrom) + ) { + newElement = newFreeDrawElementWithStrokeShape( + newElement, + elementStylesToCopyFrom.strokeShape ?? + DEFAULT_FREE_DRAW_STROKE_SHAPE, + ); + } + if (isFrameLikeElement(element)) { newElement = newElementWith(newElement, { roundness: null, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 93fe770286..ff2b3cf140 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -172,6 +172,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, + currentItemStrokeShape: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index be065d826d..98277152e5 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -394,6 +394,11 @@ const CombinedShapeProperties = ({ hasStrokeWidth(element.type), )) && renderAction("changeStrokeWidth")} + {(appState.activeTool.type === "freedraw" || + targetElements.some( + (element) => element.type === "freedraw", + )) && + renderAction("changeStrokeShape")} {(hasStrokeStyle(appState.activeTool.type) || targetElements.some((element) => hasStrokeStyle(element.type), diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..1fe6740b83 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9,6 +9,7 @@ import { clamp, pointFrom, pointDistance, + round, vector, pointRotateRads, vectorFromPoint, @@ -119,6 +120,7 @@ import { getElementAbsoluteCoords, bindOrUnbindBindingElements, fixBindingsAfterDeletion, + getFixedFreeDrawPointAction, getHoveredElementForBinding, isBindingEnabled, updateBoundElements, @@ -615,6 +617,22 @@ const gesture: Gesture = { initialScale: null, }; +const FREEDRAW_POINT_DECIMALS = 2; +const FREEDRAW_PRESSURE_DECIMALS = 3; +const FREEDRAW_DOT_EPSILON = 1 / 10 ** FREEDRAW_POINT_DECIMALS; + +const roundFreeDrawCoordinate = (value: number) => + round(value, FREEDRAW_POINT_DECIMALS); + +const roundFreeDrawPressure = (value: number) => + round(value, FREEDRAW_PRESSURE_DECIMALS); + +const getRoundedFreeDrawPoint = (x: number, y: number) => + pointFrom( + roundFreeDrawCoordinate(x), + roundFreeDrawCoordinate(y), + ); + class App extends React.Component { canvas: AppClassProperties["canvas"]; interactiveCanvas: AppClassProperties["interactiveCanvas"] = null; @@ -8833,10 +8851,11 @@ class App extends React.Component { opacity: this.state.currentItemOpacity, roundness: null, simulatePressure, + strokeShape: this.state.currentItemStrokeShape, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, points: [pointFrom(0, 0)], - pressures: simulatePressure ? [] : [event.pressure], + pressures: simulatePressure ? [] : [roundFreeDrawPressure(event.pressure)], }); this.scene.insertElement(element); @@ -10174,20 +10193,36 @@ class App extends React.Component { const points = newElement.points; const dx = pointerCoords.x - newElement.x; const dy = pointerCoords.y - newElement.y; + const nextPoint = getRoundedFreeDrawPoint(dx, dy); + const pressure = roundFreeDrawPressure(event.pressure); + const pointAction = + newElement.strokeShape === "fixed" + ? getFixedFreeDrawPointAction({ + points, + nextPoint, + strokeWidth: newElement.strokeWidth, + zoomValue: this.state.zoom.value, + }) + : points.length > 0 && + points[points.length - 1][0] === nextPoint[0] && + points[points.length - 1][1] === nextPoint[1] + ? "discard" + : "append"; - const lastPoint = points.length > 0 && points[points.length - 1]; - const discardPoint = - lastPoint && lastPoint[0] === dx && lastPoint[1] === dy; - - if (!discardPoint) { + if (pointAction !== "discard") { const pressures = newElement.simulatePressure ? newElement.pressures - : [...newElement.pressures, event.pressure]; + : pointAction === "replace" + ? [...newElement.pressures.slice(0, -1), pressure] + : [...newElement.pressures, pressure]; this.scene.mutateElement( newElement, { - points: [...points, pointFrom(dx, dy)], + points: + pointAction === "replace" + ? [...points.slice(0, -1), nextPoint] + : [...points, nextPoint], pressures, }, { @@ -10627,23 +10662,51 @@ class App extends React.Component { ); const points = newElement.points; - let dx = pointerCoords.x - newElement.x; - let dy = pointerCoords.y - newElement.y; + const dx = pointerCoords.x - newElement.x; + const dy = pointerCoords.y - newElement.y; + let nextPoint = getRoundedFreeDrawPoint(dx, dy); // Allows dots to avoid being flagged as infinitely small - if (dx === points[0][0] && dy === points[0][1]) { - dy += 0.0001; - dx += 0.0001; + if ( + nextPoint[0] === points[0][0] && + nextPoint[1] === points[0][1] + ) { + nextPoint = getRoundedFreeDrawPoint( + dx + FREEDRAW_DOT_EPSILON, + dy + FREEDRAW_DOT_EPSILON, + ); } - const pressures = newElement.simulatePressure - ? [] - : [...newElement.pressures, childEvent.pressure]; + const lastPoint = points[points.length - 1]; + const pressure = roundFreeDrawPressure(childEvent.pressure); + const pointAction = + newElement.strokeShape === "fixed" + ? getFixedFreeDrawPointAction({ + points, + nextPoint, + strokeWidth: newElement.strokeWidth, + zoomValue: this.state.zoom.value, + isFinalPoint: true, + }) + : !lastPoint || + lastPoint[0] !== nextPoint[0] || + lastPoint[1] !== nextPoint[1] + ? "append" + : "discard"; - this.scene.mutateElement(newElement, { - points: [...points, pointFrom(dx, dy)], - pressures, - }); + if (pointAction !== "discard") { + this.scene.mutateElement(newElement, { + points: + pointAction === "replace" + ? [...points.slice(0, -1), nextPoint] + : [...points, nextPoint], + pressures: newElement.simulatePressure + ? [] + : pointAction === "replace" + ? [...newElement.pressures.slice(0, -1), pressure] + : [...newElement.pressures, pressure], + }); + } this.actionManager.executeAction(actionFinalize); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 7e2400728d..42e80d90a1 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1164,7 +1164,7 @@ export const StrokeWidthBaseIcon = createIcon( modifiedTablerIconProps, ); -export const StrokeWidthBoldIcon = createIcon( +export const StrokeWidthMediumIcon = createIcon( , + modifiedTablerIconProps, +); + +export const StrokeShapeVariableIcon = createIcon( + <> + + + , + modifiedTablerIconProps, +); + +export const StrokeShapeFixedIcon = createIcon( + , + modifiedTablerIconProps, +); + export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) => createIcon( ; +const normalizeFreeDrawStrokeShape = ( + strokeShape: unknown, +): AppState["currentItemStrokeShape"] => + strokeShape === "fixed" || strokeShape === "stable" + ? "fixed" + : undefined; + export const AllowedExcalidrawActiveTools: Record< AppState["activeTool"]["type"], boolean @@ -412,10 +419,12 @@ export const restoreElement = ( return element; case "freedraw": { + const strokeShape = normalizeFreeDrawStrokeShape(element.strokeShape); return restoreElementWithProperties(element, { points: element.points, simulatePressure: element.simulatePressure, pressures: element.pressures, + ...(strokeShape ? { strokeShape } : {}), }); } case "image": @@ -944,6 +953,16 @@ export const restoreAppState = ( return { ...nextAppState, + ...(normalizeFreeDrawStrokeShape( + appState.currentItemStrokeShape ?? localAppState?.currentItemStrokeShape, + ) + ? { + currentItemStrokeShape: normalizeFreeDrawStrokeShape( + appState.currentItemStrokeShape ?? + localAppState?.currentItemStrokeShape, + ), + } + : {}), cursorButton: localAppState?.cursorButton || "up", // reset on fresh restore so as to hide the UI button if penMode not active penDetected: diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 05d3bf702e..e1955705df 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -30,6 +30,9 @@ "changeBackground": "Change background color", "fill": "Fill", "strokeWidth": "Stroke width", + "strokeShape": "Stroke type", + "strokeShape_variable": "Variable", + "strokeShape_fixed": "Fixed", "strokeStyle": "Stroke style", "strokeStyle_solid": "Solid", "strokeStyle_dashed": "Dashed", diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index de9be13c83..ddd95102fb 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -376,6 +376,11 @@ const renderElementToSvg = ( } case "freedraw": { const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); + const isFixedStroke = element.strokeShape === "fixed"; + const strokeColor = + renderConfig.theme === THEME.DARK + ? applyDarkModeFilter(element.strokeColor) + : element.strokeColor; const shapes = ShapeCache.generateElementShape(element, renderConfig); // always ordered as [background, stroke] @@ -384,12 +389,15 @@ const renderElementToSvg = ( // stroke (SVGPathString) const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path"); - path.setAttribute( - "fill", - renderConfig.theme === THEME.DARK - ? applyDarkModeFilter(element.strokeColor) - : element.strokeColor, - ); + if (isFixedStroke) { + path.setAttribute("fill", "none"); + path.setAttribute("stroke", strokeColor); + path.setAttribute("stroke-width", `${element.strokeWidth}`); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + } else { + path.setAttribute("fill", strokeColor); + } path.setAttribute("d", shape); wrapper.appendChild(path); } else { diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index bd11424b6f..6b4c63944b 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -160,6 +160,40 @@ describe("restoreElements", () => { }); }); + it("should restore fixed freedraw element correctly", () => { + const freedrawElement = API.createElement({ + type: "freedraw", + id: "id-freedraw02", + strokeShape: "fixed", + points: [pointFrom(0, 0), pointFrom(10, 10)], + }); + + const restoredFreedraw = restore.restoreElements( + [freedrawElement], + null, + )[0] as ExcalidrawFreeDrawElement; + + expect(restoredFreedraw.strokeShape).toBe("fixed"); + }); + + it("should restore legacy stable freedraw element as fixed", () => { + const restoredFreedraw = restore.restoreElements( + [ + { + ...API.createElement({ + type: "freedraw", + id: "id-freedraw03", + points: [pointFrom(0, 0), pointFrom(10, 10)], + }), + strokeShape: "stable", + } as any, + ], + null, + )[0] as ExcalidrawFreeDrawElement; + + expect(restoredFreedraw.strokeShape).toBe("fixed"); + }); + it("should restore line and draw elements correctly", () => { const lineElement = API.createElement({ type: "line", id: "id-line01" }); @@ -641,6 +675,26 @@ describe("restoreAppState", () => { expect(restoredAppState.name).toBe(stubImportedAppState.name); }); + it("should restore fixed freedraw stroke type from app state", () => { + const restoredAppState = restore.restoreAppState( + { currentItemStrokeShape: "fixed" } as ImportedDataState["appState"], + null, + ); + + expect(restoredAppState.currentItemStrokeShape).toBe("fixed"); + }); + + it("should restore legacy stable freedraw stroke type from app state as fixed", () => { + const restoredAppState = restore.restoreAppState( + { + currentItemStrokeShape: "stable", + } as unknown as ImportedDataState["appState"], + null, + ); + + expect(restoredAppState.currentItemStrokeShape).toBe("fixed"); + }); + it("should return local app state when imported data state is null", () => { const stubLocalAppState = getDefaultAppState(); stubLocalAppState.cursorButton = "down"; diff --git a/packages/excalidraw/tests/freedraw.test.tsx b/packages/excalidraw/tests/freedraw.test.tsx new file mode 100644 index 0000000000..690e098aa4 --- /dev/null +++ b/packages/excalidraw/tests/freedraw.test.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import { Excalidraw } from "../index"; +import { API } from "./helpers/api"; +import { Pointer, UI } from "./helpers/ui"; +import { render } from "./test-utils"; + +describe("freedraw", () => { + beforeEach(async () => { + await render(); + }); + + it("rounds stored points and drops duplicates after rounding", () => { + const mouse = new Pointer("mouse"); + + UI.clickTool("freedraw"); + mouse.downAt(10, 20); + mouse.moveTo(10.1234, 20.5678); + mouse.moveTo(10.1249, 20.5681); + mouse.upAt(20.9999, 30.0001); + + const freedraw = window.h.elements.at(-1); + + expect(freedraw).toEqual(expect.objectContaining({ type: "freedraw" })); + expect((freedraw as any).points).toEqual([ + [0, 0], + [0.12, 0.57], + [11, 10], + ]); + }); + + it("does not snap fixed strokes closed when ending near the start point", () => { + const mouse = new Pointer("mouse"); + + API.setAppState({ currentItemStrokeShape: "fixed" }); + UI.clickTool("freedraw"); + mouse.downAt(10, 10); + mouse.moveTo(40, 10); + mouse.moveTo(30, 30); + mouse.upAt(12, 12); + + const freedraw = window.h.elements.at(-1) as any; + + expect(freedraw.points[0]).toEqual([0, 0]); + expect(freedraw.points.at(-1)).not.toEqual([0, 0]); + }); + + it("coalesces nearly straight fixed freedraw points at the tip", () => { + const mouse = new Pointer("mouse"); + + API.setAppState({ currentItemStrokeShape: "fixed" }); + UI.clickTool("freedraw"); + mouse.downAt(10, 10); + + [ + [10.2, 10.01], + [10.4, 10.03], + [10.6, 10.02], + [10.8, 10.04], + [11, 10.03], + [11.2, 10.05], + [11.4, 10.04], + [11.6, 10.06], + [11.8, 10.05], + ].forEach(([x, y]) => { + mouse.moveTo(x, y); + }); + mouse.upAt(12, 10.05); + + const freedraw = window.h.elements.at(-1) as any; + + expect(freedraw.points.length).toBeLessThan(5); + expect(freedraw.points).toEqual([ + [0, 0], + [2, 0.05], + ]); + }); +}); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 95d8d34084..8f97fa18e5 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -201,6 +201,9 @@ export class API { ? ExcalidrawTextElement["containerId"] : never; points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never; + strokeShape?: T extends "freedraw" + ? ExcalidrawFreeDrawElement["strokeShape"] + : never; locked?: boolean; fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; @@ -318,6 +321,7 @@ export class API { type: type as "freedraw", simulatePressure: true, points: rest.points, + strokeShape: rest.strokeShape ?? appState.currentItemStrokeShape, ...base, }); break; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 079af75969..ac4d097591 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -28,6 +28,7 @@ import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { TransformHandleType } from "@excalidraw/element"; import type { ExcalidrawElement, + ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, ExcalidrawArrowElement, @@ -432,8 +433,10 @@ type DrawingToolName = Exclude< "lock" | "selection" | "eraser" | "lasso" >; -type Element = T extends "line" | "freedraw" +type Element = T extends "line" ? ExcalidrawLinearElement + : T extends "freedraw" + ? ExcalidrawFreeDrawElement : T extends "arrow" ? ExcalidrawArrowElement : T extends "text" diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index a41512fe64..f4c8727894 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -33,6 +33,7 @@ import type { ExcalidrawNonSelectionElement, BindMode, ExcalidrawTextElement, + FreeDrawStrokeShape, } from "@excalidraw/element/types"; import type { @@ -358,6 +359,7 @@ export interface AppState { currentItemFillStyle: ExcalidrawElement["fillStyle"]; currentItemStrokeWidth: number; currentItemStrokeStyle: ExcalidrawElement["strokeStyle"]; + currentItemStrokeShape?: FreeDrawStrokeShape; currentItemRoughness: number; currentItemOpacity: number; currentItemFontFamily: FontFamilyValues;