mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Segments detection
This commit is contained in:
parent
9cf17321f1
commit
89a733120f
3 changed files with 321 additions and 133 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { degreesToRadians } from "./angle";
|
import { degreesToRadians, radiansToDegrees } from "./angle";
|
||||||
import type {
|
import type {
|
||||||
LocalPoint,
|
LocalPoint,
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
|
@ -7,7 +7,7 @@ import type {
|
||||||
Vector,
|
Vector,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { PRECISION } from "./utils";
|
import { PRECISION } from "./utils";
|
||||||
import { vectorFromPoint, vectorScale } from "./vector";
|
import { vectorDot, vectorFromPoint, vectorScale } from "./vector";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a properly typed Point instance from the X and Y coordinates.
|
* Create a properly typed Point instance from the X and Y coordinates.
|
||||||
|
@ -229,3 +229,61 @@ export const isPointWithinBounds = <P extends GlobalPoint | LocalPoint>(
|
||||||
q[1] >= Math.min(p[1], r[1])
|
q[1] >= Math.min(p[1], r[1])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the perpendicular distance from a point to a line segment defined by two endpoints.
|
||||||
|
*
|
||||||
|
* If the segment is of zero length, the function returns the distance from the point to the start.
|
||||||
|
*
|
||||||
|
* @typeParam P - The point type, restricted to LocalPoint or GlobalPoint.
|
||||||
|
* @param p - The point from which the perpendicular distance is measured.
|
||||||
|
* @param start - The starting point of the line segment.
|
||||||
|
* @param end - The ending point of the line segment.
|
||||||
|
* @returns The perpendicular distance from point p to the line segment defined by start and end.
|
||||||
|
*/
|
||||||
|
export const perpendicularDistance = <P extends GlobalPoint | LocalPoint> (
|
||||||
|
p: P,
|
||||||
|
start: P,
|
||||||
|
end: P):
|
||||||
|
number => {
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
return Math.hypot(p[0] - start[0], p[1] - start[1]);
|
||||||
|
}
|
||||||
|
// Equation of line distance
|
||||||
|
const numerator = Math.abs(dy * p[0] - dx * p[1] + end[0] * start[1] - end[1] * start[0]);
|
||||||
|
const denom = Math.hypot(dx, dy);
|
||||||
|
return numerator / denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** * Calculates the angle between three points in degrees.
|
||||||
|
* The angle is calculated at the first point (p0) using the second (p1) and third (p2) points.
|
||||||
|
* The angle is measured in degrees and is always positive.
|
||||||
|
* The function uses the dot product and the arccosine function to calculate the angle. * The result is clamped to the range [-1, 1] to avoid precision errors.
|
||||||
|
* @param p0 The first point used to form the angle.
|
||||||
|
* @param p1 The vertex point where the angle is calculated.
|
||||||
|
* @param p2 The second point used to form the angle.
|
||||||
|
* @returns The angle in degrees between the three points.
|
||||||
|
**/
|
||||||
|
export const angleBetween = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p0: P,
|
||||||
|
p1: P,
|
||||||
|
p2: P,
|
||||||
|
): Degrees => {
|
||||||
|
const v1 = vectorFromPoint(p0, p1);
|
||||||
|
const v2 = vectorFromPoint(p1, p2);
|
||||||
|
|
||||||
|
// dot and cross product
|
||||||
|
const magnitude1 = Math.hypot(v1[0], v1[1]), magnitude2 = Math.hypot(v2[0], v2[1]);
|
||||||
|
if (magnitude1 === 0 || magnitude2 === 0) return 0 as Degrees;
|
||||||
|
|
||||||
|
const dot = vectorDot(v1, v2);
|
||||||
|
|
||||||
|
let cos = dot / (magnitude1 * magnitude2);
|
||||||
|
// Clamp cos to [-1,1] to avoid precision errors
|
||||||
|
cos = Math.max(-1, Math.min(1, cos));
|
||||||
|
const rad = Math.acos(cos) as Radians;
|
||||||
|
|
||||||
|
return radiansToDegrees(rad);
|
||||||
|
}
|
||||||
|
|
|
@ -48,7 +48,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amaplex-software/shapeit": "0.1.6",
|
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.29.1",
|
||||||
|
|
|
@ -6,131 +6,262 @@ import type {
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawRectangleElement,
|
||||||
} from "../excalidraw/element/types";
|
} from "../excalidraw/element/types";
|
||||||
import type { BoundingBox } from "../excalidraw/element/bounds";
|
import type { BoundingBox, Bounds } from "../excalidraw/element/bounds";
|
||||||
import { getCommonBoundingBox } from "../excalidraw/element/bounds";
|
import { getCenterForBounds, getCommonBoundingBox } from "../excalidraw/element/bounds";
|
||||||
import { newElement } from "../excalidraw/element";
|
import { newArrowElement, newElement, newLinearElement } from "../excalidraw/element";
|
||||||
// @ts-ignore
|
import { angleBetween, GlobalPoint, LocalPoint, perpendicularDistance, pointDistance } from "@excalidraw/math";
|
||||||
import shapeit from "@amaplex-software/shapeit";
|
import { ROUNDNESS } from "@excalidraw/excalidraw/constants";
|
||||||
|
|
||||||
type Shape =
|
type Shape =
|
||||||
| ExcalidrawRectangleElement["type"]
|
| ExcalidrawRectangleElement["type"]
|
||||||
| ExcalidrawEllipseElement["type"]
|
| ExcalidrawEllipseElement["type"]
|
||||||
| ExcalidrawDiamondElement["type"]
|
| ExcalidrawDiamondElement["type"]
|
||||||
// | ExcalidrawArrowElement["type"]
|
| ExcalidrawArrowElement["type"]
|
||||||
// | ExcalidrawLinearElement["type"]
|
| ExcalidrawLinearElement["type"]
|
||||||
| ExcalidrawFreeDrawElement["type"];
|
| ExcalidrawFreeDrawElement["type"];
|
||||||
|
|
||||||
interface ShapeRecognitionResult {
|
interface ShapeRecognitionResult {
|
||||||
type: Shape;
|
type: Shape;
|
||||||
confidence: number;
|
simplified: readonly LocalPoint[];
|
||||||
boundingBox: BoundingBox;
|
boundingBox: BoundingBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ShapeRecognitionOptions {
|
||||||
|
closedDistThreshPercent: number; // Max distance between stroke start/end to consider shape closed
|
||||||
|
cornerAngleThresh: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection)
|
||||||
|
rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal)
|
||||||
|
rectAngleThresh: number; // Angle (in degrees) to check for rectangle corners
|
||||||
|
rectOrientationThresh: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: ShapeRecognitionOptions = {
|
||||||
|
closedDistThreshPercent: 10, // distance between start/end < % of bounding box diagonal
|
||||||
|
cornerAngleThresh: 60, // <60° considered a sharp corner (possible arrow tip)
|
||||||
|
rdpTolerancePercent: 10, // percentage of bounding box diagonal
|
||||||
|
rectAngleThresh: 20, // <20° considered a sharp corner (rectangle)
|
||||||
|
rectOrientationThresh: 10, //
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
* Recognizes common shapes from free-draw input
|
* Recognizes common shapes from free-draw input
|
||||||
* @param element The freedraw element to analyze
|
* @param element The freedraw element to analyze
|
||||||
* @returns Information about the recognized shape, or null if no shape is recognized
|
* @returns Information about the recognized shape, or null if no shape is recognized
|
||||||
*/
|
*/
|
||||||
export const recognizeShape = (
|
export const recognizeShape = (
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
): ShapeRecognitionResult => {
|
opts: Partial<ShapeRecognitionOptions> = {},
|
||||||
const boundingBox = getCommonBoundingBox([element]);
|
): ShapeRecognitionResult => {
|
||||||
|
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||||
|
|
||||||
|
const boundingBox = getCommonBoundingBox([element]);;
|
||||||
|
|
||||||
// We need at least a few points to recognize a shape
|
// We need at least a few points to recognize a shape
|
||||||
if (!element.points || element.points.length < 3) {
|
if (!element.points || element.points.length < 3) {
|
||||||
return { type: "freedraw", confidence: 1, boundingBox };
|
return { type: "freedraw", simplified: element.points, boundingBox };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Recognizing shape from points:", element.points);
|
const tolerance = pointDistance(
|
||||||
|
[boundingBox.minX, boundingBox.minY] as LocalPoint,
|
||||||
|
[boundingBox.maxX, boundingBox.maxY] as LocalPoint,
|
||||||
|
) * options.rdpTolerancePercent / 100;
|
||||||
|
const simplified = simplifyRDP(element.points, tolerance);
|
||||||
|
console.log("Simplified points:", simplified);
|
||||||
|
|
||||||
const shapethat = shapeit.new({
|
// Check if the original points form a closed shape
|
||||||
atlas: {},
|
const start = element.points[0], end = element.points[element.points.length - 1];
|
||||||
output: {},
|
const closedDist = pointDistance(start, end);
|
||||||
thresholds: {},
|
const diag = Math.hypot(boundingBox.width, boundingBox.height); // diagonal of bounding box
|
||||||
|
const isClosed = closedDist < Math.max(10, diag * options.closedDistThreshPercent / 100); // e.g., threshold: 10px or % of size
|
||||||
|
console.log("Closed shape:", isClosed);
|
||||||
|
|
||||||
|
let bestShape: Shape = 'freedraw'; // TODO: Should this even be possible in this mode?
|
||||||
|
|
||||||
|
const boundingBoxCenter = getCenterForBounds([
|
||||||
|
boundingBox.minX,
|
||||||
|
boundingBox.minY,
|
||||||
|
boundingBox.maxX,
|
||||||
|
boundingBox.maxY,
|
||||||
|
] as Bounds);
|
||||||
|
|
||||||
|
// **Line** (open shape with low deviation from a straight line)
|
||||||
|
if (!isClosed && simplified.length == 2) {
|
||||||
|
bestShape = 'line';
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Arrow** (open shape with a sharp angle indicating an arrowhead)
|
||||||
|
if (!isClosed && simplified.length == 5) {
|
||||||
|
// The last two segments will make an arrowhead
|
||||||
|
console.log("Simplified points:", simplified);
|
||||||
|
const arrow_start = simplified[2], arrow_tip = simplified[3], arrow_end = simplified[4];
|
||||||
|
const tipAngle = angleBetween(arrow_tip, arrow_start, arrow_end); // angle at the second-last point (potential arrow tip)
|
||||||
|
// Lengths of the last two segments
|
||||||
|
|
||||||
|
const seg1Len = pointDistance(arrow_start, arrow_tip);
|
||||||
|
const seg2Len = pointDistance(arrow_tip, arrow_end);
|
||||||
|
// Length of the rest of the stroke (approx arrow shaft length)
|
||||||
|
const shaftLen = pointDistance(simplified[0], simplified[1])
|
||||||
|
// Heuristic checks for arrowhead: sharp angle and short segments relative to shaft
|
||||||
|
console.log("Arrow tip angle:", tipAngle);
|
||||||
|
if (tipAngle > 30 && tipAngle < 150 && seg1Len < shaftLen * 0.8 && seg2Len < shaftLen * 0.8) {
|
||||||
|
bestShape = 'arrow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Rectangle or Diamond** (closed shape with 4 corners - RDP might include last point
|
||||||
|
if (isClosed && (simplified.length == 4 || simplified.length == 5)) {
|
||||||
|
const vertices = simplified.slice(); // copy
|
||||||
|
if (simplified.length === 5) {
|
||||||
|
vertices.pop(); // remove last point if RDP included it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute angles at each corner
|
||||||
|
console.log("Vertices:", vertices);
|
||||||
|
var angles = []
|
||||||
|
for (let i = 0; i < vertices.length; i++) {
|
||||||
|
angles.push(angleBetween(vertices[i], vertices[(i + 1) % vertices.length], vertices[(i + 2) % vertices.length]));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Angles:", angles);
|
||||||
|
console.log("Angles sum:", angles.reduce((a, b) => a + b, 0));
|
||||||
|
|
||||||
|
// All angles are sharp enough, so we can check for rectangle/diamond
|
||||||
|
if (angles.every(a => (a > options.rectAngleThresh && a < 180 - options.rectAngleThresh))) {
|
||||||
|
// Determine orientation by checking the slope of each segment
|
||||||
|
interface Segment { length: number; angleDeg: number; }
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const p1 = simplified[i];
|
||||||
|
const p2 = simplified[(i + 1) % (simplified.length)];
|
||||||
|
const dx = p2[0] - p1[0];
|
||||||
|
const dy = p2[1] - p1[1];
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
// angle of segment in degrees from horizontal
|
||||||
|
let segAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
if (segAngle < 0) segAngle += 360;
|
||||||
|
if (segAngle > 180) segAngle -= 180; // use [0,180] range for undirected line
|
||||||
|
segments.push({ length, angleDeg: segAngle });
|
||||||
|
}
|
||||||
|
// Check for axis-aligned orientation
|
||||||
|
const hasAxisAlignedSide = segments.some(seg => {
|
||||||
|
const angle = seg.angleDeg;
|
||||||
|
const distToHoriz = Math.min(Math.abs(angle - 0), Math.abs(angle - 180));
|
||||||
|
const distToVert = Math.abs(angle - 90);
|
||||||
|
return (distToHoriz < options.rectOrientationThresh) || (distToVert < options.rectOrientationThresh);
|
||||||
});
|
});
|
||||||
|
if (hasAxisAlignedSide) {
|
||||||
const shape = shapethat(element.points);
|
bestShape = "rectangle";
|
||||||
|
} else {
|
||||||
console.log("Shape recognized:", shape);
|
// Not near axis-aligned, likely a rotated shape -> diamond
|
||||||
|
bestShape = "diamond";
|
||||||
const mappedShape = (name: string): Shape => {
|
}
|
||||||
switch (name) {
|
}
|
||||||
case "rectangle":
|
} else {
|
||||||
return "rectangle";
|
const aspectRatio = boundingBox.width && boundingBox.height ? Math.min(boundingBox.width, boundingBox.height) / Math.max(boundingBox.width, boundingBox.height) : 1;
|
||||||
case "square":
|
// If aspect ratio ~1 (nearly square) and simplified has few corners, good for circle
|
||||||
return "rectangle";
|
if (aspectRatio > 0.8) {
|
||||||
case "circle":
|
// Measure radius variance
|
||||||
return "ellipse";
|
const cx = boundingBoxCenter[0];
|
||||||
case "open polygon":
|
const cy = boundingBoxCenter[1];
|
||||||
return "diamond";
|
let totalDist = 0, maxDist = 0, minDist = Infinity;
|
||||||
default:
|
for (const p of simplified) {
|
||||||
return "freedraw";
|
const d = Math.hypot(p[0] - cx, p[1] - cy);
|
||||||
|
totalDist += d;
|
||||||
|
maxDist = Math.max(maxDist, d);
|
||||||
|
minDist = Math.min(minDist, d);
|
||||||
|
}
|
||||||
|
const avgDist = totalDist / simplified.length;
|
||||||
|
const radiusVar = (maxDist - minDist) / (avgDist || 1);
|
||||||
|
// If variance in radius is small, shape is round
|
||||||
|
if (radiusVar < 0.3) {
|
||||||
|
bestShape = 'ellipse';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const recognizedShape: ShapeRecognitionResult = {
|
return {
|
||||||
type: mappedShape(shape.name),
|
type: bestShape,
|
||||||
confidence: 0.8,
|
simplified,
|
||||||
boundingBox,
|
boundingBox
|
||||||
};
|
} as ShapeRecognitionResult;
|
||||||
|
};
|
||||||
|
|
||||||
return recognizedShape;
|
/**
|
||||||
};
|
* Simplify a polyline using Ramer-Douglas-Peucker algorithm.
|
||||||
|
* @param points Array of points [x,y] representing the stroke.
|
||||||
/**
|
* @param epsilon Tolerance for simplification (higher = more simplification).
|
||||||
* Creates a new element based on the recognized shape from a freedraw element
|
* @returns Simplified list of points.
|
||||||
* @param freedrawElement The original freedraw element
|
|
||||||
* @param recognizedShape The recognized shape information
|
|
||||||
* @returns A new element of the recognized shape type
|
|
||||||
*/
|
*/
|
||||||
export const createElementFromRecognizedShape = (
|
function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly LocalPoint[] {
|
||||||
freedrawElement: ExcalidrawFreeDrawElement,
|
if (points.length < 3) return points;
|
||||||
recognizedShape: ShapeRecognitionResult,
|
// Find the point with the maximum distance from the line between first and last
|
||||||
): ExcalidrawElement => {
|
const first = points[0], last = points[points.length - 1];
|
||||||
if (!recognizedShape.type || recognizedShape.type === "freedraw") {
|
let index = -1;
|
||||||
return freedrawElement;
|
let maxDist = 0;
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
// Perpendicular distance from points[i] to line (first-last)
|
||||||
|
const dist = perpendicularDistance(points[i], first, last);
|
||||||
|
if (dist > maxDist) {
|
||||||
|
maxDist = dist;
|
||||||
|
index = i;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// If max distance is greater than epsilon, recursively simplify
|
||||||
|
if (maxDist > epsilon && index !== -1) {
|
||||||
|
const left = simplifyRDP(points.slice(0, index + 1), epsilon);
|
||||||
|
const right = simplifyRDP(points.slice(index), epsilon);
|
||||||
|
// Concatenate results (omit duplicate point at junction)
|
||||||
|
return left.slice(0, -1).concat(right);
|
||||||
|
} else {
|
||||||
|
// Not enough deviation, return straight line (keep only endpoints)
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (recognizedShape.type === "rectangle") {
|
/**
|
||||||
|
* Converts a freedraw element to the detected shape
|
||||||
|
*/
|
||||||
|
export const convertToShape = (
|
||||||
|
freeDrawElement: ExcalidrawFreeDrawElement,
|
||||||
|
): ExcalidrawElement => {
|
||||||
|
const recognizedShape = recognizeShape(freeDrawElement);
|
||||||
|
|
||||||
|
switch (recognizedShape.type) {
|
||||||
|
case "rectangle": case "diamond": case "ellipse": {
|
||||||
return newElement({
|
return newElement({
|
||||||
...freedrawElement,
|
...freeDrawElement,
|
||||||
|
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||||
type: recognizedShape.type,
|
type: recognizedShape.type,
|
||||||
x: recognizedShape.boundingBox.minX,
|
x: recognizedShape.boundingBox.minX,
|
||||||
y: recognizedShape.boundingBox.minY,
|
y: recognizedShape.boundingBox.minY,
|
||||||
width: recognizedShape.boundingBox.width!,
|
width: recognizedShape.boundingBox.width!,
|
||||||
height: recognizedShape.boundingBox.height!,
|
height: recognizedShape.boundingBox.height!,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if shape recognition should be applied based on app state
|
|
||||||
* @param element The freedraw element to potentially snap
|
|
||||||
* @param minConfidence The minimum confidence level required to apply snapping
|
|
||||||
* @returns Whether to apply shape snapping
|
|
||||||
*/
|
|
||||||
export const shouldApplyShapeSnapping = (
|
|
||||||
recognizedShape: ShapeRecognitionResult,
|
|
||||||
minConfidence: number = 0.75,
|
|
||||||
): boolean => {
|
|
||||||
return (
|
|
||||||
!!recognizedShape.type && (recognizedShape.confidence || 0) >= minConfidence
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a freedraw element to the detected shape
|
|
||||||
*/
|
|
||||||
export const convertToShape = (
|
|
||||||
freeDrawElement: ExcalidrawFreeDrawElement,
|
|
||||||
): ExcalidrawElement => {
|
|
||||||
const recognizedShape = recognizeShape(freeDrawElement);
|
|
||||||
|
|
||||||
if (shouldApplyShapeSnapping(recognizedShape)) {
|
|
||||||
return createElementFromRecognizedShape(freeDrawElement, recognizedShape);
|
|
||||||
}
|
}
|
||||||
|
case "arrow": {
|
||||||
// Add more shape conversions as needed
|
return newArrowElement({
|
||||||
return freeDrawElement;
|
...freeDrawElement,
|
||||||
};
|
type: recognizedShape.type,
|
||||||
|
endArrowhead: "arrow", // TODO: Get correct state
|
||||||
|
points: [
|
||||||
|
recognizedShape.simplified[0],
|
||||||
|
recognizedShape.simplified[recognizedShape.simplified.length - 2]
|
||||||
|
],
|
||||||
|
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "line": {
|
||||||
|
return newLinearElement({
|
||||||
|
...freeDrawElement,
|
||||||
|
type: recognizedShape.type,
|
||||||
|
points: [
|
||||||
|
recognizedShape.simplified[0],
|
||||||
|
recognizedShape.simplified[recognizedShape.simplified.length - 1]
|
||||||
|
],
|
||||||
|
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default: return freeDrawElement
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue