diff options
Diffstat (limited to 'packages/excalidraw/shapes.tsx')
| -rw-r--r-- | packages/excalidraw/shapes.tsx | 493 |
1 files changed, 493 insertions, 0 deletions
diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx new file mode 100644 index 0000000..cfd639f --- /dev/null +++ b/packages/excalidraw/shapes.tsx @@ -0,0 +1,493 @@ +import { + isPoint, + pointFrom, + pointDistance, + pointFromPair, + pointRotateRads, + pointsEqual, + type GlobalPoint, + type LocalPoint, +} from "@excalidraw/math"; +import { + getClosedCurveShape, + getCurvePathOps, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, + type GeometricShape, +} from "@excalidraw/utils/geometry/shape"; +import { + ArrowIcon, + DiamondIcon, + EllipseIcon, + EraserIcon, + FreedrawIcon, + ImageIcon, + LineIcon, + RectangleIcon, + SelectionIcon, + TextIcon, +} from "./components/icons"; +import { + DEFAULT_ADAPTIVE_RADIUS, + DEFAULT_PROPORTIONAL_RADIUS, + LINE_CONFIRM_THRESHOLD, + ROUNDNESS, +} from "./constants"; +import { getElementAbsoluteCoords } from "./element"; +import type { Bounds } from "./element/bounds"; +import { shouldTestInside } from "./element/collision"; +import { LinearElementEditor } from "./element/linearElementEditor"; +import { getBoundTextElement } from "./element/textElement"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "./element/types"; +import { KEYS } from "./keys"; +import { ShapeCache } from "./scene/ShapeCache"; +import type { NormalizedZoomValue, Zoom } from "./types"; +import { invariant } from "./utils"; + +export const SHAPES = [ + { + icon: SelectionIcon, + value: "selection", + key: KEYS.V, + numericKey: KEYS["1"], + fillable: true, + }, + { + icon: RectangleIcon, + value: "rectangle", + key: KEYS.R, + numericKey: KEYS["2"], + fillable: true, + }, + { + icon: DiamondIcon, + value: "diamond", + key: KEYS.D, + numericKey: KEYS["3"], + fillable: true, + }, + { + icon: EllipseIcon, + value: "ellipse", + key: KEYS.O, + numericKey: KEYS["4"], + fillable: true, + }, + { + icon: ArrowIcon, + value: "arrow", + key: KEYS.A, + numericKey: KEYS["5"], + fillable: true, + }, + { + icon: LineIcon, + value: "line", + key: KEYS.L, + numericKey: KEYS["6"], + fillable: true, + }, + { + icon: FreedrawIcon, + value: "freedraw", + key: [KEYS.P, KEYS.X], + numericKey: KEYS["7"], + fillable: false, + }, + { + icon: TextIcon, + value: "text", + key: KEYS.T, + numericKey: KEYS["8"], + fillable: false, + }, + { + icon: ImageIcon, + value: "image", + key: null, + numericKey: KEYS["9"], + fillable: false, + }, + { + icon: EraserIcon, + value: "eraser", + key: KEYS.E, + numericKey: KEYS["0"], + fillable: false, + }, +] as const; + +export const findShapeByKey = (key: string) => { + const shape = SHAPES.find((shape, index) => { + return ( + (shape.numericKey != null && key === shape.numericKey.toString()) || + (shape.key && + (typeof shape.key === "string" + ? shape.key === key + : (shape.key as readonly string[]).includes(key))) + ); + }); + return shape?.value || null; +}; + +/** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ +export const getElementShape = <Point extends GlobalPoint | LocalPoint>( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GeometricShape<Point> => { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + + return shouldTestInside(element) + ? getClosedCurveShape<Point>( + element, + roughShape, + pointFrom<Point>(element.x, element.y), + element.angle, + pointFrom(cx, cy), + ) + : getCurveShape<Point>( + roughShape, + pointFrom<Point>(element.x, element.y), + element.angle, + pointFrom(cx, cy), + ); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + return getFreedrawShape( + element, + pointFrom(cx, cy), + shouldTestInside(element), + ); + } + } +}; + +export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GeometricShape<Point> | null => { + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (boundTextElement) { + if (element.type === "arrow") { + return getElementShape( + { + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ), + }, + elementsMap, + ); + } + return getElementShape(boundTextElement, elementsMap); + } + + return null; +}; + +export const getControlPointsForBezierCurve = < + P extends GlobalPoint | LocalPoint, +>( + element: NonDeleted<ExcalidrawLinearElement>, + endPoint: P, +) => { + const shape = ShapeCache.generateElementShape(element, null); + if (!shape) { + return null; + } + + const ops = getCurvePathOps(shape[0]); + let currentP = pointFrom<P>(0, 0); + let index = 0; + let minDistance = Infinity; + let controlPoints: P[] | null = null; + + while (index < ops.length) { + const { op, data } = ops[index]; + if (op === "move") { + invariant( + isPoint(data), + "The returned ops is not compatible with a point", + ); + currentP = pointFromPair(data); + } + if (op === "bcurveTo") { + const p0 = currentP; + const p1 = pointFrom<P>(data[0], data[1]); + const p2 = pointFrom<P>(data[2], data[3]); + const p3 = pointFrom<P>(data[4], data[5]); + const distance = pointDistance(p3, endPoint); + if (distance < minDistance) { + minDistance = distance; + controlPoints = [p0, p1, p2, p3]; + } + currentP = p3; + } + index++; + } + + return controlPoints; +}; + +export const getBezierXY = <P extends GlobalPoint | LocalPoint>( + p0: P, + p1: P, + p2: P, + p3: P, + t: number, +): P => { + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + const tx = equation(t, 0); + const ty = equation(t, 1); + return pointFrom(tx, ty); +}; + +const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + endPoint: P, +) => { + const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!; + if (!controlPoints) { + return []; + } + const pointsOnCurve: P[] = []; + let t = 1; + // Take 20 points on curve for better accuracy + while (t > 0) { + const p = getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ); + pointsOnCurve.push(pointFrom(p[0], p[1])); + t -= 0.05; + } + if (pointsOnCurve.length) { + if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) { + pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1])); + } + } + return pointsOnCurve; +}; + +const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + endPoint: P, +) => { + const arcLengths: number[] = []; + arcLengths[0] = 0; + const points = getPointsInBezierCurve(element, endPoint); + let index = 0; + let distance = 0; + while (index < points.length - 1) { + const segmentDistance = pointDistance(points[index], points[index + 1]); + distance += segmentDistance; + arcLengths.push(distance); + index++; + } + + return arcLengths; +}; + +export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + endPoint: P, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + return arcLengths.at(-1) as number; +}; + +// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length +export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + endPoint: P, + interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + const pointsCount = arcLengths.length - 1; + const curveLength = arcLengths.at(-1) as number; + const targetLength = interval * curveLength; + let low = 0; + let high = pointsCount; + let index = 0; + // Doing a binary search to find the largest length that is less than the target length + while (low < high) { + index = Math.floor(low + (high - low) / 2); + if (arcLengths[index] < targetLength) { + low = index + 1; + } else { + high = index; + } + } + if (arcLengths[index] > targetLength) { + index--; + } + if (arcLengths[index] === targetLength) { + return index / pointsCount; + } + + return ( + 1 - + (index + + (targetLength - arcLengths[index]) / + (arcLengths[index + 1] - arcLengths[index])) / + pointsCount + ); +}; + +/** + * Get the axis-aligned bounding box for a given element + */ +export const aabbForElement = ( + element: Readonly<ExcalidrawElement>, + offset?: [number, number, number, number], +) => { + const bbox = { + minX: element.x, + minY: element.y, + maxX: element.x + element.width, + maxY: element.y + element.height, + midX: element.x + element.width / 2, + midY: element.y + element.height / 2, + }; + + const center = pointFrom(bbox.midX, bbox.midY); + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(bbox.minX, bbox.minY), + center, + element.angle, + ); + const [topRightX, topRightY] = pointRotateRads( + pointFrom(bbox.maxX, bbox.minY), + center, + element.angle, + ); + const [bottomRightX, bottomRightY] = pointRotateRads( + pointFrom(bbox.maxX, bbox.maxY), + center, + element.angle, + ); + const [bottomLeftX, bottomLeftY] = pointRotateRads( + pointFrom(bbox.minX, bbox.maxY), + center, + element.angle, + ); + + const bounds = [ + Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), + Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), + ] as Bounds; + + if (offset) { + const [topOffset, rightOffset, downOffset, leftOffset] = offset; + return [ + bounds[0] - leftOffset, + bounds[1] - topOffset, + bounds[2] + rightOffset, + bounds[3] + downOffset, + ] as Bounds; + } + + return bounds; +}; + +export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>( + p: P, + bounds: Bounds, +): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; + +export const aabbsOverlapping = (a: Bounds, b: Bounds) => + pointInsideBounds(pointFrom(a[0], a[1]), b) || + pointInsideBounds(pointFrom(a[2], a[1]), b) || + pointInsideBounds(pointFrom(a[2], a[3]), b) || + pointInsideBounds(pointFrom(a[0], a[3]), b) || + pointInsideBounds(pointFrom(b[0], b[1]), a) || + pointInsideBounds(pointFrom(b[2], b[1]), a) || + pointInsideBounds(pointFrom(b[2], b[3]), a) || + pointInsideBounds(pointFrom(b[0], b[3]), a); + +export const getCornerRadius = (x: number, element: ExcalidrawElement) => { + if ( + element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || + element.roundness?.type === ROUNDNESS.LEGACY + ) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { + const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; + + const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; + + if (x <= CUTOFF_SIZE) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + return fixedRadiusSize; + } + + return 0; +}; + +// Checks if the first and last point are close enough +// to be considered a loop +export const isPathALoop = ( + points: ExcalidrawLinearElement["points"], + /** supply if you want the loop detection to account for current zoom */ + zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, +): boolean => { + if (points.length >= 3) { + const [first, last] = [points[0], points[points.length - 1]]; + const distance = pointDistance(first, last); + + // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in + // really close we make the threshold smaller, and vice versa. + return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; + } + return false; +}; |
