feat: improved freedraw (#3512)

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

View file

@ -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(" ");
}