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,
|
maxY + element.y,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
const shape = getShapeForElement(element) as Drawable[];
|
const shape = getShapeForElement(element)!;
|
||||||
|
|
||||||
// first element is always the curve
|
// first element is always the curve
|
||||||
const ops = getCurvePathOps(shape[0]);
|
const ops = getCurvePathOps(shape[0]);
|
||||||
@ -326,7 +326,7 @@ const getLinearElementRotatedBounds = (
|
|||||||
return [minX, minY, maxX, maxY];
|
return [minX, minY, maxX, maxY];
|
||||||
}
|
}
|
||||||
|
|
||||||
const shape = getShapeForElement(element) as Drawable[];
|
const shape = getShapeForElement(element)!;
|
||||||
|
|
||||||
// first element is always the curve
|
// first element is always the curve
|
||||||
const ops = getCurvePathOps(shape[0]);
|
const ops = getCurvePathOps(shape[0]);
|
||||||
|
@ -24,6 +24,7 @@ import {
|
|||||||
NonDeleted,
|
NonDeleted,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||||
@ -361,6 +362,14 @@ const hitTestFreeDrawElement = (
|
|||||||
B = element.points[i + 1];
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -383,7 +392,11 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
|
|||||||
}
|
}
|
||||||
const [relX, relY] = GAPoint.toTuple(point);
|
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) {
|
if (args.check === isInsideCheck) {
|
||||||
const hit = shape.some((subshape) =>
|
const hit = shape.some((subshape) =>
|
||||||
@ -821,7 +834,7 @@ const hitTestCurveInside = (
|
|||||||
sharpness: ExcalidrawElement["strokeSharpness"],
|
sharpness: ExcalidrawElement["strokeSharpness"],
|
||||||
) => {
|
) => {
|
||||||
const ops = getCurvePathOps(drawable);
|
const ops = getCurvePathOps(drawable);
|
||||||
const points: Point[] = [];
|
const points: Mutable<Point>[] = [];
|
||||||
let odd = false; // select one line out of double lines
|
let odd = false; // select one line out of double lines
|
||||||
for (const operation of ops) {
|
for (const operation of ops) {
|
||||||
if (operation.op === "move") {
|
if (operation.op === "move") {
|
||||||
@ -835,13 +848,17 @@ const hitTestCurveInside = (
|
|||||||
points.push([operation.data[2], operation.data[3]]);
|
points.push([operation.data[2], operation.data[3]]);
|
||||||
points.push([operation.data[4], operation.data[5]]);
|
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 (points.length >= 4) {
|
||||||
if (sharpness === "sharp") {
|
if (sharpness === "sharp") {
|
||||||
return isPointInPolygon(points, x, y);
|
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 isPointInPolygon(polygonPoints, x, y);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -896,9 +913,10 @@ const hitTestRoughShape = (
|
|||||||
// position of the previous operation
|
// position of the previous operation
|
||||||
return retVal;
|
return retVal;
|
||||||
} else if (op === "lineTo") {
|
} else if (op === "lineTo") {
|
||||||
// TODO: Implement this
|
return hitTestCurveInside(drawable, x, y, "sharp");
|
||||||
} else if (op === "qcurveTo") {
|
} else if (op === "qcurveTo") {
|
||||||
// TODO: Implement this
|
// TODO: Implement this
|
||||||
|
console.warn("qcurveTo is not implemented yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
@ -196,7 +196,7 @@ const drawElementOnCanvas = (
|
|||||||
case "ellipse": {
|
case "ellipse": {
|
||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
rc.draw(getShapeForElement(element) as Drawable);
|
rc.draw(getShapeForElement(element)!);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "arrow":
|
case "arrow":
|
||||||
@ -204,7 +204,7 @@ const drawElementOnCanvas = (
|
|||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
|
|
||||||
(getShapeForElement(element) as Drawable[]).forEach((shape) => {
|
getShapeForElement(element)!.forEach((shape) => {
|
||||||
rc.draw(shape);
|
rc.draw(shape);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -215,6 +215,11 @@ const drawElementOnCanvas = (
|
|||||||
context.fillStyle = element.strokeColor;
|
context.fillStyle = element.strokeColor;
|
||||||
|
|
||||||
const path = getFreeDrawPath2D(element) as Path2D;
|
const path = getFreeDrawPath2D(element) as Path2D;
|
||||||
|
const fillShape = getShapeForElement(element);
|
||||||
|
|
||||||
|
if (fillShape) {
|
||||||
|
rc.draw(fillShape);
|
||||||
|
}
|
||||||
|
|
||||||
context.fillStyle = element.strokeColor;
|
context.fillStyle = element.strokeColor;
|
||||||
context.fill(path);
|
context.fill(path);
|
||||||
@ -290,13 +295,29 @@ const elementWithCanvasCache = new WeakMap<
|
|||||||
ExcalidrawElementWithCanvas
|
ExcalidrawElementWithCanvas
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const shapeCache = new WeakMap<
|
const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
ExcalidrawElement,
|
|
||||||
Drawable | Drawable[] | null
|
|
||||||
>();
|
|
||||||
|
|
||||||
export const getShapeForElement = (element: ExcalidrawElement) =>
|
type ElementShape = Drawable | Drawable[] | null;
|
||||||
shapeCache.get(element);
|
|
||||||
|
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) =>
|
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||||
shapeCache.delete(element);
|
shapeCache.delete(element);
|
||||||
@ -346,7 +367,8 @@ export const generateRoughOptions = (
|
|||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
case "line": {
|
case "line":
|
||||||
|
case "freedraw": {
|
||||||
if (isPathALoop(element.points)) {
|
if (isPathALoop(element.points)) {
|
||||||
options.fillStyle = element.fillStyle;
|
options.fillStyle = element.fillStyle;
|
||||||
options.fill =
|
options.fill =
|
||||||
@ -356,7 +378,6 @@ export const generateRoughOptions = (
|
|||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
case "freedraw":
|
|
||||||
case "arrow":
|
case "arrow":
|
||||||
return options;
|
return options;
|
||||||
default: {
|
default: {
|
||||||
@ -374,9 +395,11 @@ const generateElementShape = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
generator: RoughGenerator,
|
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);
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@ -402,6 +425,8 @@ const generateElementShape = (
|
|||||||
generateRoughOptions(element),
|
generateRoughOptions(element),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setShapeForElement(element, shape);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "diamond": {
|
case "diamond": {
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
@ -445,6 +470,8 @@ const generateElementShape = (
|
|||||||
generateRoughOptions(element),
|
generateRoughOptions(element),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setShapeForElement(element, shape);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
@ -455,6 +482,8 @@ const generateElementShape = (
|
|||||||
element.height,
|
element.height,
|
||||||
generateRoughOptions(element),
|
generateRoughOptions(element),
|
||||||
);
|
);
|
||||||
|
setShapeForElement(element, shape);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "line":
|
case "line":
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
@ -578,21 +607,32 @@ const generateElementShape = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShapeForElement(element, shape);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
generateFreeDrawShape(element);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "text":
|
case "text":
|
||||||
case "image": {
|
case "image": {
|
||||||
// just to ensure we don't regenerate element.canvas on rerenders
|
// just to ensure we don't regenerate element.canvas on rerenders
|
||||||
shape = [];
|
setShapeForElement(element, null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shapeCache.set(element, shape);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -808,7 +848,7 @@ export const renderElementToSvg = (
|
|||||||
generateElementShape(element, generator);
|
generateElementShape(element, generator);
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
getShapeForElement(element) as Drawable,
|
getShapeForElement(element)!,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
);
|
);
|
||||||
const opacity = element.opacity / 100;
|
const opacity = element.opacity / 100;
|
||||||
@ -833,7 +873,7 @@ export const renderElementToSvg = (
|
|||||||
const opacity = element.opacity / 100;
|
const opacity = element.opacity / 100;
|
||||||
group.setAttribute("stroke-linecap", "round");
|
group.setAttribute("stroke-linecap", "round");
|
||||||
|
|
||||||
(getShapeForElement(element) as Drawable[]).forEach((shape) => {
|
getShapeForElement(element)!.forEach((shape) => {
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
shape,
|
shape,
|
||||||
@ -864,7 +904,10 @@ export const renderElementToSvg = (
|
|||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
generateFreeDrawShape(element);
|
generateFreeDrawShape(element);
|
||||||
const opacity = element.opacity / 100;
|
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) {
|
if (opacity !== 1) {
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
node.setAttribute("fill-opacity", `${opacity}`);
|
node.setAttribute("fill-opacity", `${opacity}`);
|
||||||
@ -875,9 +918,9 @@ export const renderElementToSvg = (
|
|||||||
offsetY || 0
|
offsetY || 0
|
||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
);
|
);
|
||||||
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
|
|
||||||
node.setAttribute("stroke", "none");
|
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));
|
path.setAttribute("d", getFreeDrawSvgPath(element));
|
||||||
node.appendChild(path);
|
node.appendChild(path);
|
||||||
svgRoot.appendChild(node);
|
svgRoot.appendChild(node);
|
||||||
|
@ -9,7 +9,8 @@ export const hasBackground = (type: string) =>
|
|||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
type === "ellipse" ||
|
type === "ellipse" ||
|
||||||
type === "diamond" ||
|
type === "diamond" ||
|
||||||
type === "line";
|
type === "line" ||
|
||||||
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: string) => type !== "image";
|
export const hasStrokeColor = (type: string) => type !== "image";
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user