feat: support background fill for freedraw shapes (#4610)

* feat: support background fill for freedraw shapes

* refactor & support fill style

* make filled freedraw shapes selectable from inside

* get hit test on solid freedraw shapes to somewhat work

* fix SVG export of unclosed freedraw shapes & improve types

* fix lint

* type tweaks

* reuse `hitTestCurveInside` for collision tests

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Arun 2022-02-09 22:13:21 +05:30 committed by GitHub
parent ae8b1d8bf7
commit 0cdd0eebf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 89 additions and 27 deletions

View File

@ -185,7 +185,7 @@ const getLinearElementAbsoluteCoords = (
maxY + element.y,
];
} else {
const shape = getShapeForElement(element) as Drawable[];
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
@ -326,7 +326,7 @@ const getLinearElementRotatedBounds = (
return [minX, minY, maxX, maxY];
}
const shape = getShapeForElement(element) as Drawable[];
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);

View File

@ -24,6 +24,7 @@ import {
NonDeleted,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -361,6 +362,14 @@ const hitTestFreeDrawElement = (
B = element.points[i + 1];
}
const shape = getShapeForElement(element);
// for filled freedraw shapes, support
// selecting from inside
if (shape && shape.sets.length) {
return hitTestRoughShape(shape, x, y, threshold);
}
return false;
};
@ -383,7 +392,11 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
}
const [relX, relY] = GAPoint.toTuple(point);
const shape = getShapeForElement(element) as Drawable[];
const shape = getShapeForElement(element as ExcalidrawLinearElement);
if (!shape) {
return false;
}
if (args.check === isInsideCheck) {
const hit = shape.some((subshape) =>
@ -821,7 +834,7 @@ const hitTestCurveInside = (
sharpness: ExcalidrawElement["strokeSharpness"],
) => {
const ops = getCurvePathOps(drawable);
const points: Point[] = [];
const points: Mutable<Point>[] = [];
let odd = false; // select one line out of double lines
for (const operation of ops) {
if (operation.op === "move") {
@ -835,13 +848,17 @@ const hitTestCurveInside = (
points.push([operation.data[2], operation.data[3]]);
points.push([operation.data[4], operation.data[5]]);
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push([operation.data[0], operation.data[1]]);
}
}
}
if (points.length >= 4) {
if (sharpness === "sharp") {
return isPointInPolygon(points, x, y);
}
const polygonPoints = pointsOnBezierCurves(points as any, 10, 5);
const polygonPoints = pointsOnBezierCurves(points, 10, 5);
return isPointInPolygon(polygonPoints, x, y);
}
return false;
@ -896,9 +913,10 @@ const hitTestRoughShape = (
// position of the previous operation
return retVal;
} else if (op === "lineTo") {
// TODO: Implement this
return hitTestCurveInside(drawable, x, y, "sharp");
} else if (op === "qcurveTo") {
// TODO: Implement this
console.warn("qcurveTo is not implemented yet");
}
return false;

View File

@ -196,7 +196,7 @@ const drawElementOnCanvas = (
case "ellipse": {
context.lineJoin = "round";
context.lineCap = "round";
rc.draw(getShapeForElement(element) as Drawable);
rc.draw(getShapeForElement(element)!);
break;
}
case "arrow":
@ -204,7 +204,7 @@ const drawElementOnCanvas = (
context.lineJoin = "round";
context.lineCap = "round";
(getShapeForElement(element) as Drawable[]).forEach((shape) => {
getShapeForElement(element)!.forEach((shape) => {
rc.draw(shape);
});
break;
@ -215,6 +215,11 @@ const drawElementOnCanvas = (
context.fillStyle = element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = getShapeForElement(element);
if (fillShape) {
rc.draw(fillShape);
}
context.fillStyle = element.strokeColor;
context.fill(path);
@ -290,13 +295,29 @@ const elementWithCanvasCache = new WeakMap<
ExcalidrawElementWithCanvas
>();
const shapeCache = new WeakMap<
ExcalidrawElement,
Drawable | Drawable[] | null
>();
const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
export const getShapeForElement = (element: ExcalidrawElement) =>
shapeCache.get(element);
type ElementShape = Drawable | Drawable[] | null;
type ElementShapes = {
freedraw: Drawable | null;
arrow: Drawable[];
line: Drawable[];
text: null;
image: null;
};
export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
shapeCache.get(element) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: Drawable | null | undefined;
export const setShapeForElement = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => shapeCache.set(element, shape);
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
shapeCache.delete(element);
@ -346,7 +367,8 @@ export const generateRoughOptions = (
}
return options;
}
case "line": {
case "line":
case "freedraw": {
if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle;
options.fill =
@ -356,7 +378,6 @@ export const generateRoughOptions = (
}
return options;
}
case "freedraw":
case "arrow":
return options;
default: {
@ -374,9 +395,11 @@ const generateElementShape = (
element: NonDeletedExcalidrawElement,
generator: RoughGenerator,
) => {
let shape = shapeCache.get(element) || null;
let shape = shapeCache.get(element);
if (!shape) {
// `null` indicates no rc shape applicable for this element type
// (= do not generate anything)
if (shape === undefined) {
elementWithCanvasCache.delete(element);
switch (element.type) {
@ -402,6 +425,8 @@ const generateElementShape = (
generateRoughOptions(element),
);
}
setShapeForElement(element, shape);
break;
case "diamond": {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
@ -445,6 +470,8 @@ const generateElementShape = (
generateRoughOptions(element),
);
}
setShapeForElement(element, shape);
break;
}
case "ellipse":
@ -455,6 +482,8 @@ const generateElementShape = (
element.height,
generateRoughOptions(element),
);
setShapeForElement(element, shape);
break;
case "line":
case "arrow": {
@ -578,21 +607,32 @@ const generateElementShape = (
}
}
setShapeForElement(element, shape);
break;
}
case "freedraw": {
generateFreeDrawShape(element);
shape = [];
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
shape = generator.polygon(element.points as [number, number][], {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
}
setShapeForElement(element, shape);
break;
}
case "text":
case "image": {
// just to ensure we don't regenerate element.canvas on rerenders
shape = [];
setShapeForElement(element, null);
break;
}
}
shapeCache.set(element, shape);
}
};
@ -808,7 +848,7 @@ export const renderElementToSvg = (
generateElementShape(element, generator);
const node = roughSVGDrawWithPrecision(
rsvg,
getShapeForElement(element) as Drawable,
getShapeForElement(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
@ -833,7 +873,7 @@ export const renderElementToSvg = (
const opacity = element.opacity / 100;
group.setAttribute("stroke-linecap", "round");
(getShapeForElement(element) as Drawable[]).forEach((shape) => {
getShapeForElement(element)!.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
@ -864,7 +904,10 @@ export const renderElementToSvg = (
case "freedraw": {
generateFreeDrawShape(element);
const opacity = element.opacity / 100;
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
const shape = getShapeForElement(element);
const node = shape
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
@ -875,9 +918,9 @@ export const renderElementToSvg = (
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
node.setAttribute("stroke", "none");
node.setAttribute("fill", element.strokeColor);
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
svgRoot.appendChild(node);

View File

@ -9,7 +9,8 @@ export const hasBackground = (type: string) =>
type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "line";
type === "line" ||
type === "freedraw";
export const hasStrokeColor = (type: string) => type !== "image";