|
@@ -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<ExcalidrawFreeDrawElement, Path2D>([]);
|
|
|
+
|
|
|
+export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
|
|
+ const svgPathData = getFreeDrawSvgPath(element);
|
|
|
+ const path = new Path2D(svgPathData);
|
|
|
+ pathsCache.set(element, path);
|
|
|
+ return path;
|
|
|
+}
|
|
|
+
|
|
|
+export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|
|
+ return pathsCache.get(element);
|
|
|
+}
|
|
|
+
|
|
|
+export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|
|
+ const inputPoints = element.simulatePressure
|
|
|
+ ? element.points
|
|
|
+ : element.points.length
|
|
|
+ ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
|
|
+ : [[0, 0, 0]];
|
|
|
+
|
|
|
+ // Consider changing the options for simulated pressure vs real pressure
|
|
|
+ const options = {
|
|
|
+ simulatePressure: element.simulatePressure,
|
|
|
+ size: element.strokeWidth * 6,
|
|
|
+ thinning: 0.5,
|
|
|
+ smoothing: 0.5,
|
|
|
+ streamline: 0.5,
|
|
|
+ easing: (t: number) => t * (2 - t),
|
|
|
+ last: true,
|
|
|
+ };
|
|
|
+
|
|
|
+ const points = getFreeDrawShape(inputPoints as number[][], options);
|
|
|
+ const d: (string | number)[] = [];
|
|
|
+
|
|
|
+ let [p0, p1] = points;
|
|
|
+
|
|
|
+ d.push("M", p0[0], p0[1], "Q");
|
|
|
+
|
|
|
+ for (let i = 0; i < points.length; i++) {
|
|
|
+ d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
|
|
|
+ p0 = p1;
|
|
|
+ p1 = points[i];
|
|
|
+ }
|
|
|
+
|
|
|
+ p1 = points[0];
|
|
|
+ d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
|
|
|
+
|
|
|
+ d.push("Z");
|
|
|
+
|
|
|
+ return d.join(" ");
|
|
|
+}
|