mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: improved freedraw (#3512)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
198800136e
commit
49c6bdd520
66 changed files with 786 additions and 247 deletions
|
@ -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(" ");
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue