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:
parent
ae8b1d8bf7
commit
0cdd0eebf1
@ -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]);
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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";
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user