diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 84fa608b..8290b1ce 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -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]); diff --git a/src/element/collision.ts b/src/element/collision.ts index 4eb6405e..c00953aa 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -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[] = []; 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; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index ae7eb9ea..126d6677 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -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(); -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 = (element: T) => + shapeCache.get(element) as T["type"] extends keyof ElementShapes + ? ElementShapes[T["type"]] | undefined + : Drawable | null | undefined; + +export const setShapeForElement = ( + 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); diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index 618f2966..24b98d40 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -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";