mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
First implementation of element distance functions with failing tests
This commit is contained in:
parent
47cc842415
commit
d9ea7190ec
6 changed files with 275 additions and 173 deletions
|
@ -25,7 +25,6 @@ import type {
|
|||
ExcalidrawElbowArrowElement,
|
||||
FixedPoint,
|
||||
SceneElementsMap,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
|
@ -63,7 +62,7 @@ import {
|
|||
vectorToHeading,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import {
|
||||
segment,
|
||||
point,
|
||||
|
@ -76,6 +75,7 @@ import {
|
|||
radians,
|
||||
} from "../../math";
|
||||
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
|
@ -557,10 +557,7 @@ const calculateFocusAndGap = (
|
|||
edgePoint,
|
||||
elementsMap,
|
||||
),
|
||||
gap: Math.max(
|
||||
1,
|
||||
distanceToBindableElement(hoveredElement, edgePoint, elementsMap),
|
||||
),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -736,11 +733,7 @@ const getDistanceForBinding = (
|
|||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const distance = distanceToBindableElement(
|
||||
bindableElement,
|
||||
point,
|
||||
elementsMap,
|
||||
);
|
||||
const distance = distanceToBindableElement(bindableElement, point);
|
||||
const bindDistance = maxBindingGap(
|
||||
bindableElement,
|
||||
bindableElement.width,
|
||||
|
@ -781,9 +774,7 @@ export const bindPointToSnapToElementOutline = (
|
|||
const isVertical =
|
||||
compareHeading(heading, HEADING_LEFT) ||
|
||||
compareHeading(heading, HEADING_RIGHT);
|
||||
const dist = Math.abs(
|
||||
distanceToBindableElement(bindableElement, p, elementsMap),
|
||||
);
|
||||
const dist = Math.abs(distanceToBindableElement(bindableElement, p));
|
||||
const isInner = isVertical
|
||||
? dist < bindableElement.width * -0.1
|
||||
: dist < bindableElement.height * -0.1;
|
||||
|
@ -937,7 +928,7 @@ export const snapToMid = (
|
|||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
const nonRotated = pointRotateRads(p, center, radians(-angle));
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
|
@ -1123,7 +1114,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||
const nonRotatedSnappedGlobalPoint = pointRotateRads(
|
||||
snappedPoint,
|
||||
globalMidPoint,
|
||||
-hoveredElement.angle as Radians,
|
||||
radians(-hoveredElement.angle),
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -1351,148 +1342,6 @@ export const maxBindingGap = (
|
|||
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
|
||||
};
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectangle(element, point, elementsMap);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point, elementsMap);
|
||||
case "ellipse":
|
||||
return distanceToEllipse(element, point, elementsMap);
|
||||
}
|
||||
};
|
||||
|
||||
const distanceToRectangle = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
element,
|
||||
p,
|
||||
elementsMap,
|
||||
);
|
||||
return Math.max(
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
);
|
||||
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
|
||||
return GAPoint.distanceToLine(pointRel, side);
|
||||
};
|
||||
|
||||
const distanceToEllipse = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
|
||||
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
|
||||
};
|
||||
|
||||
const ellipseParamsForTest = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): [GA.Point, GA.Line] => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
);
|
||||
const [px, py] = GAPoint.toTuple(pointRel);
|
||||
|
||||
// We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
|
||||
let tx = 0.707;
|
||||
let ty = 0.707;
|
||||
|
||||
const a = hwidth;
|
||||
const b = hheight;
|
||||
|
||||
// This is a numerical method to find the params tx, ty at which
|
||||
// the ellipse has the closest point to the given point
|
||||
[0, 1, 2, 3].forEach((_) => {
|
||||
const xx = a * tx;
|
||||
const yy = b * ty;
|
||||
|
||||
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||
|
||||
const rx = xx - ex;
|
||||
const ry = yy - ey;
|
||||
|
||||
const qx = px - ex;
|
||||
const qy = py - ey;
|
||||
|
||||
const r = Math.hypot(ry, rx);
|
||||
const q = Math.hypot(qy, qx);
|
||||
|
||||
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||
const t = Math.hypot(ty, tx);
|
||||
tx /= t;
|
||||
ty /= t;
|
||||
});
|
||||
|
||||
const closestPoint = GA.point(a * tx, b * ty);
|
||||
|
||||
const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
|
||||
return [pointRel, tangent];
|
||||
};
|
||||
|
||||
// Returns:
|
||||
// 1. the point relative to the elements (x, y) position
|
||||
// 2. the point relative to the element's center with positive (x, y)
|
||||
// 3. half element width
|
||||
// 4. half element height
|
||||
//
|
||||
// Note that for linear elements the (x, y) position is not at the
|
||||
// top right corner of their boundary.
|
||||
//
|
||||
// Rectangles, diamonds and ellipses are symmetrical over axes,
|
||||
// and other elements have a rectangular boundary,
|
||||
// so we only need to perform hit tests for the positive quadrant.
|
||||
const pointRelativeToElement = (
|
||||
element: ExcalidrawElement,
|
||||
pointTuple: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
const relativizationToElementCenter = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
|
|
210
packages/excalidraw/element/distance.ts
Normal file
210
packages/excalidraw/element/distance.ts
Normal file
|
@ -0,0 +1,210 @@
|
|||
import type { GlobalPoint, Segment } from "../../math";
|
||||
import {
|
||||
arc,
|
||||
arcDistanceFromPoint,
|
||||
ellipse,
|
||||
ellipseDistanceFromPoint,
|
||||
ellipseSegmentInterceptPoints,
|
||||
point,
|
||||
pointRotateRads,
|
||||
radians,
|
||||
rectangle,
|
||||
segment,
|
||||
segmentDistanceToPoint,
|
||||
} from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectangleElement(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, point);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, point);
|
||||
}
|
||||
};
|
||||
|
||||
export const distanceToRectangleElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = point(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const r = rectangle(
|
||||
pointRotateRads(
|
||||
point(element.x, element.y),
|
||||
center,
|
||||
radians(element.angle),
|
||||
),
|
||||
pointRotateRads(
|
||||
point(element.x + element.width, element.y + element.height),
|
||||
center,
|
||||
radians(element.angle),
|
||||
),
|
||||
);
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
const rotatedPoint = pointRotateRads(p, center, element.angle);
|
||||
const sideDistances = [
|
||||
segment(
|
||||
point(r[0][0] + roundness, r[0][1]),
|
||||
point(r[1][0] - roundness, r[0][1]),
|
||||
),
|
||||
segment(
|
||||
point(r[1][0], r[0][1] + roundness),
|
||||
point(r[1][0], r[1][1] - roundness),
|
||||
),
|
||||
segment(
|
||||
point(r[1][0] - roundness, r[1][1]),
|
||||
point(r[0][0] + roundness, r[1][1]),
|
||||
),
|
||||
segment(
|
||||
point(r[0][0], r[1][1] - roundness),
|
||||
point(r[0][0], r[0][1] + roundness),
|
||||
),
|
||||
].map((s) => segmentDistanceToPoint(rotatedPoint, s));
|
||||
const cornerDistances =
|
||||
roundness > 0
|
||||
? [
|
||||
arc(
|
||||
point(r[0][0] + roundness, r[0][1] + roundness),
|
||||
roundness,
|
||||
radians(Math.PI),
|
||||
radians((3 / 4) * Math.PI),
|
||||
),
|
||||
arc(
|
||||
point(r[1][0] - roundness, r[0][1] + roundness),
|
||||
roundness,
|
||||
radians((3 / 4) * Math.PI),
|
||||
radians(0),
|
||||
),
|
||||
arc(
|
||||
point(r[1][0] - roundness, r[1][1] - roundness),
|
||||
roundness,
|
||||
radians(0),
|
||||
radians((1 / 2) * Math.PI),
|
||||
),
|
||||
arc(
|
||||
point(r[0][0] + roundness, r[1][1] - roundness),
|
||||
roundness,
|
||||
radians((1 / 2) * Math.PI),
|
||||
radians(Math.PI),
|
||||
),
|
||||
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
|
||||
: [];
|
||||
|
||||
return Math.min(...[...sideDistances, ...cornerDistances]);
|
||||
};
|
||||
|
||||
const roundedCutoffSegment = (
|
||||
s: Segment<GlobalPoint>,
|
||||
r: number,
|
||||
): Segment<GlobalPoint> => {
|
||||
const t = (4 * r) / Math.sqrt(2);
|
||||
|
||||
return segment(
|
||||
ellipseSegmentInterceptPoints(ellipse(s[0], radians(0), t, t), s)[0],
|
||||
ellipseSegmentInterceptPoints(ellipse(s[1], radians(0), t, t), s)[0],
|
||||
);
|
||||
};
|
||||
|
||||
const diamondArc = (left: GlobalPoint, right: GlobalPoint, r: number) => {
|
||||
const c = point((left[0] + right[0]) / 2, left[1]);
|
||||
|
||||
return arc(
|
||||
c,
|
||||
r,
|
||||
radians(Math.asin((left[1] - c[1]) / r)),
|
||||
radians(Math.asin((right[1] - c[1]) / r)),
|
||||
);
|
||||
};
|
||||
|
||||
export const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = point<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
const rotatedPoint = pointRotateRads(p, center, element.angle);
|
||||
const top = pointRotateRads<GlobalPoint>(
|
||||
point(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const right = pointRotateRads<GlobalPoint>(
|
||||
point(element.x + element.width, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const bottom = pointRotateRads<GlobalPoint>(
|
||||
point(element.x + element.width / 2, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const left = pointRotateRads<GlobalPoint>(
|
||||
point(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const topRight = roundedCutoffSegment(segment(top, right), roundness);
|
||||
const bottomRight = roundedCutoffSegment(segment(right, bottom), roundness);
|
||||
const bottomLeft = roundedCutoffSegment(segment(bottom, left), roundness);
|
||||
const topLeft = roundedCutoffSegment(segment(left, top), roundness);
|
||||
|
||||
return Math.min(
|
||||
...[
|
||||
...[topRight, bottomRight, bottomLeft, topLeft].map((s) =>
|
||||
segmentDistanceToPoint(rotatedPoint, s),
|
||||
),
|
||||
...(roundness > 0
|
||||
? [
|
||||
diamondArc(topLeft[1], topRight[0], roundness),
|
||||
diamondArc(topRight[1], bottomRight[0], roundness),
|
||||
diamondArc(bottomRight[1], bottomLeft[0], roundness),
|
||||
diamondArc(bottomLeft[1], topLeft[0], roundness),
|
||||
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
|
||||
: []),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
export const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
return ellipseDistanceFromPoint(
|
||||
p,
|
||||
ellipse(
|
||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||
element.angle,
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
),
|
||||
);
|
||||
};
|
|
@ -140,10 +140,10 @@ export const findShapeByKey = (key: string) => {
|
|||
* get the pure geometric shape of an excalidraw element
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
export const getElementShape = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
): GeometricShape<GlobalPoint> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
|
@ -163,16 +163,16 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
? getClosedCurveShape<GlobalPoint>(
|
||||
element,
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
point<GlobalPoint>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
: getCurveShape<GlobalPoint>(
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
point<GlobalPoint>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
);
|
||||
|
@ -192,10 +192,10 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|||
}
|
||||
};
|
||||
|
||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
export const getBoundTextShape = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> | null => {
|
||||
): GeometricShape<GlobalPoint> | null => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { invariant } from "../excalidraw/utils";
|
||||
import { cartesian2Polar, radians } from "./angle";
|
||||
import { ellipse, ellipseSegmentInterceptPoints } from "./ellipse";
|
||||
import { point } from "./point";
|
||||
import { point, pointDistance } from "./point";
|
||||
import { segment } from "./segment";
|
||||
import type { GenericPoint, Segment, Radians, Arc } from "./types";
|
||||
import { PRECISION } from "./utils";
|
||||
|
||||
|
@ -42,6 +44,25 @@ export function arcIncludesPoint<P extends GenericPoint>(
|
|||
: startAngle <= angle || endAngle >= angle;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param a
|
||||
* @param p
|
||||
*/
|
||||
export function arcDistanceFromPoint<Point extends GenericPoint>(
|
||||
a: Arc<Point>,
|
||||
p: Point,
|
||||
) {
|
||||
const intersectPoint = arcSegmentInterceptPoint(a, segment(p, a.center));
|
||||
|
||||
invariant(
|
||||
intersectPoint.length !== 1,
|
||||
"Arc distance intersector cannot have multiple intersections",
|
||||
);
|
||||
|
||||
return pointDistance(intersectPoint[0], p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intersection point(s) of a line segment represented by a start
|
||||
* point and end point and a symmetric arc.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { pointsEqual } from "./point";
|
||||
import { segment, segmentIncludesPoint } from "./segment";
|
||||
import type { GenericPoint, Polygon } from "./types";
|
||||
import { segment, segmentIncludesPoint, segmentsIntersectAt } from "./segment";
|
||||
import type { GenericPoint, Polygon, Segment } from "./types";
|
||||
import { PRECISION } from "./utils";
|
||||
|
||||
export function polygon<Point extends GenericPoint>(...points: Point[]) {
|
||||
|
@ -62,3 +62,25 @@ function polygonClose<Point extends GenericPoint>(polygon: Point[]) {
|
|||
function polygonIsClosed<Point extends GenericPoint>(polygon: Point[]) {
|
||||
return pointsEqual(polygon[0], polygon[polygon.length - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the points of intersection of a line segment, identified by exactly
|
||||
* one start pointand one end point, and the polygon identified by a set of
|
||||
* ponits representing a set of connected lines.
|
||||
*/
|
||||
export function polygonSegmentIntersectionPoints<Point extends GenericPoint>(
|
||||
polygon: Readonly<Polygon<Point>>,
|
||||
segment: Readonly<Segment<Point>>,
|
||||
): Point[] {
|
||||
return polygon
|
||||
.reduce((segments, current, idx, poly) => {
|
||||
return idx === 0
|
||||
? []
|
||||
: ([
|
||||
...segments,
|
||||
[poly[idx - 1] as Point, current],
|
||||
] as Segment<Point>[]);
|
||||
}, [] as Segment<Point>[])
|
||||
.map((s) => segmentsIntersectAt(s, segment))
|
||||
.filter((point) => point !== null) as Point[];
|
||||
}
|
||||
|
|
|
@ -122,11 +122,11 @@ export const segmentIncludesPoint = <Point extends GenericPoint>(
|
|||
};
|
||||
|
||||
export const segmentDistanceToPoint = <Point extends GenericPoint>(
|
||||
point: Point,
|
||||
line: Segment<Point>,
|
||||
p: Point,
|
||||
s: Segment<Point>,
|
||||
) => {
|
||||
const [x, y] = point;
|
||||
const [[x1, y1], [x2, y2]] = line;
|
||||
const [x, y] = p;
|
||||
const [[x1, y1], [x2, y2]] = s;
|
||||
|
||||
const A = x - x1;
|
||||
const B = y - y1;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue