mirror of
https://github.com/grafana/grafana.git
synced 2026-08-08 20:28:14 -05:00
Canvas: Fix division by zero (#85443)
* Canvas: Fix division by zero * Fix last add vertex control * Apply fix to existing vertices * Apply deltas to temporary paths * Persist original source and target coorindates * Add follow up TODO * Update connection svg to handle originals * Get rid of minimum constraints for deltas * Apply persist coordinate system to vertex handlers * Consolidate and rename selected value * Update connection arcs to use new coordinates * fix editor crashing with scenes --------- Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
co-authored by
Adela Almasan
parent
e528c79689
commit
ed8eacbc7e
+2
@@ -83,8 +83,10 @@ export interface CanvasConnection {
|
||||
path: ConnectionPath;
|
||||
size?: ui.ScaleDimensionConfig;
|
||||
source: ConnectionCoordinates;
|
||||
sourceOriginal?: ConnectionCoordinates;
|
||||
target: ConnectionCoordinates;
|
||||
targetName?: string;
|
||||
targetOriginal?: ConnectionCoordinates;
|
||||
vertices?: Array<ConnectionCoordinates>;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface CanvasConnection {
|
||||
vertices?: ConnectionCoordinates[];
|
||||
radius?: ScaleDimensionConfig;
|
||||
direction?: ConnectionDirection;
|
||||
sourceOriginal?: ConnectionCoordinates;
|
||||
targetOriginal?: ConnectionCoordinates;
|
||||
// See https://github.com/anseki/leader-line#options for more examples of more properties
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { ConnectionCoordinates } from '../../panelcfg.gen';
|
||||
import { ConnectionState } from '../../types';
|
||||
import {
|
||||
calculateAbsoluteCoords,
|
||||
calculateAngle,
|
||||
calculateCoordinates,
|
||||
calculateDistance,
|
||||
@@ -140,9 +139,18 @@ export const ConnectionSVG = ({
|
||||
}
|
||||
|
||||
const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (v.sourceOriginal && v.targetOriginal) {
|
||||
xStart = v.sourceOriginal.x;
|
||||
yStart = v.sourceOriginal.y;
|
||||
xEnd = v.targetOriginal.x;
|
||||
yEnd = v.targetOriginal.y;
|
||||
}
|
||||
|
||||
const midpoint = calculateMidpoint(x1, y1, x2, y2);
|
||||
const xDist = x2 - x1;
|
||||
const yDist = y2 - y1;
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
const { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle, shouldAnimate } =
|
||||
getConnectionStyles(info, scene, defaultArrowSize, defaultArrowDirection);
|
||||
@@ -165,8 +173,8 @@ export const ConnectionSVG = ({
|
||||
const y = vertex.y;
|
||||
|
||||
// Convert vertex relative coordinates to scene coordinates
|
||||
const X = x * xDist + x1;
|
||||
const Y = y * yDist + y1;
|
||||
const X = x * xDist + xStart;
|
||||
const Y = y * yDist + yStart;
|
||||
|
||||
// Initialize coordinates for first arc control point
|
||||
let xa = X;
|
||||
@@ -184,17 +192,17 @@ export const ConnectionSVG = ({
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
if (index < vertices.length - 1) {
|
||||
const Xn = vertices[index + 1].x * xDist + x1;
|
||||
const Yn = vertices[index + 1].y * yDist + y1;
|
||||
const Xn = vertices[index + 1].x * xDist + xStart;
|
||||
const Yn = vertices[index + 1].y * yDist + yStart;
|
||||
if (index === 0) {
|
||||
// First vertex
|
||||
angle1 = calculateAngle(x1, y1, X, Y);
|
||||
angle1 = calculateAngle(xStart, yStart, X, Y);
|
||||
angle2 = calculateAngle(X, Y, Xn, Yn);
|
||||
} else {
|
||||
// All vertices
|
||||
const previousVertex = vertices[index - 1];
|
||||
const Xp = previousVertex.x * xDist + x1;
|
||||
const Yp = previousVertex.y * yDist + y1;
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
angle1 = calculateAngle(Xp, Yp, X, Y);
|
||||
angle2 = calculateAngle(X, Y, Xn, Yn);
|
||||
}
|
||||
@@ -205,8 +213,8 @@ export const ConnectionSVG = ({
|
||||
// Not also the first vertex
|
||||
previousVertex = vertices[index - 1];
|
||||
}
|
||||
const Xp = previousVertex.x * xDist + x1;
|
||||
const Yp = previousVertex.y * yDist + y1;
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
angle1 = calculateAngle(Xp, Yp, X, Y);
|
||||
angle2 = calculateAngle(X, Y, x2, y2);
|
||||
}
|
||||
@@ -224,12 +232,14 @@ export const ConnectionSVG = ({
|
||||
|
||||
if (index === 0) {
|
||||
// For first vertex
|
||||
addVertices.push(calculateMidpoint(0, 0, x, y));
|
||||
addVertices.push(
|
||||
calculateMidpoint((x1 - xStart) / (xEnd - xStart), (y1 - yStart) / (yEnd - yStart), x, y)
|
||||
);
|
||||
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
// Length of segment
|
||||
const lSegment = calculateDistance(X, Y, x1, y1);
|
||||
const lSegment = calculateDistance(X, Y, xStart, yStart);
|
||||
if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) {
|
||||
// Limit curve control points to mid segment
|
||||
lHalfArc = 0.5 * lSegment;
|
||||
@@ -240,8 +250,8 @@ export const ConnectionSVG = ({
|
||||
if (index < vertices.length - 1) {
|
||||
// Not also the last point
|
||||
const nextVertex = vertices[index + 1];
|
||||
Xn = nextVertex.x * xDist + x1;
|
||||
Yn = nextVertex.y * yDist + y1;
|
||||
Xn = nextVertex.x * xDist + xStart;
|
||||
Yn = nextVertex.y * yDist + yStart;
|
||||
}
|
||||
|
||||
// Length of next segment
|
||||
@@ -252,15 +262,15 @@ export const ConnectionSVG = ({
|
||||
}
|
||||
// Calculate arc control points
|
||||
const lDelta = lSegment - lHalfArc;
|
||||
xa = lDelta * Math.cos(angle1) + x1;
|
||||
ya = lDelta * Math.sin(angle1) + y1;
|
||||
xa = lDelta * Math.cos(angle1) + xStart;
|
||||
ya = lDelta * Math.sin(angle1) + yStart;
|
||||
xb = lHalfArc * Math.cos(angle2) + X;
|
||||
yb = lHalfArc * Math.sin(angle2) + Y;
|
||||
|
||||
// Check if arc control points are inside of segment, otherwise swap sign
|
||||
if ((xa > X && xa > x1) || (xa < X && xa < x1)) {
|
||||
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + x1;
|
||||
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + y1;
|
||||
if ((xa > X && xa > xStart) || (xa < X && xa < xStart)) {
|
||||
xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + xStart;
|
||||
ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + yStart;
|
||||
xb = -lHalfArc * Math.cos(angle2) + X;
|
||||
yb = -lHalfArc * Math.sin(angle2) + Y;
|
||||
}
|
||||
@@ -273,8 +283,8 @@ export const ConnectionSVG = ({
|
||||
// Only calculate arcs if there is a radius
|
||||
if (radius) {
|
||||
// Convert previous vertex relative coorindates to scene coordinates
|
||||
const Xp = previousVertex.x * xDist + x1;
|
||||
const Yp = previousVertex.y * yDist + y1;
|
||||
const Xp = previousVertex.x * xDist + xStart;
|
||||
const Yp = previousVertex.y * yDist + yStart;
|
||||
|
||||
// Length of segment
|
||||
const lSegment = calculateDistance(X, Y, Xp, Yp);
|
||||
@@ -288,8 +298,8 @@ export const ConnectionSVG = ({
|
||||
if (index < vertices.length - 1) {
|
||||
// Not also the last point
|
||||
const nextVertex = vertices[index + 1];
|
||||
Xn = nextVertex.x * xDist + x1;
|
||||
Yn = nextVertex.y * yDist + y1;
|
||||
Xn = nextVertex.x * xDist + xStart;
|
||||
Yn = nextVertex.y * yDist + yStart;
|
||||
}
|
||||
|
||||
// Length of next segment
|
||||
@@ -317,7 +327,9 @@ export const ConnectionSVG = ({
|
||||
}
|
||||
if (index === vertices.length - 1) {
|
||||
// For last vertex only
|
||||
addVertices.push(calculateMidpoint(1, 1, x, y));
|
||||
addVertices.push(
|
||||
calculateMidpoint((x2 - xStart) / (xEnd - xStart), (y2 - yStart) / (yEnd - yStart), x, y)
|
||||
);
|
||||
}
|
||||
// Add segment to path
|
||||
pathString += `L${xa} ${ya} `;
|
||||
@@ -414,14 +426,13 @@ export const ConnectionSVG = ({
|
||||
{isSelected && (
|
||||
<g>
|
||||
{vertices.map((value, index) => {
|
||||
const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y);
|
||||
return (
|
||||
<circle
|
||||
id={CONNECTION_VERTEX_ID}
|
||||
data-index={index}
|
||||
key={`${CONNECTION_VERTEX_ID}${index}_${idx}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
cx={value.x * xDist + xStart}
|
||||
cy={value.y * yDist + yStart}
|
||||
r={5}
|
||||
stroke={strokeColor}
|
||||
className={styles.vertex}
|
||||
@@ -432,14 +443,13 @@ export const ConnectionSVG = ({
|
||||
})}
|
||||
{vertices.length < maximumVertices &&
|
||||
addVertices.map((value, index) => {
|
||||
const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y);
|
||||
return (
|
||||
<circle
|
||||
id={CONNECTION_VERTEX_ADD_ID}
|
||||
data-index={index}
|
||||
key={`${CONNECTION_VERTEX_ADD_ID}${index}_${idx}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
cx={value.x * xDist + xStart}
|
||||
cy={value.y * yDist + yStart}
|
||||
r={4}
|
||||
stroke={strokeColor}
|
||||
className={styles.addVertex}
|
||||
|
||||
@@ -302,32 +302,41 @@ export class Connections {
|
||||
this.connectionVertex?.setAttribute('cx', `${x}`);
|
||||
this.connectionVertex?.setAttribute('cy', `${y}`);
|
||||
|
||||
const sourceRect = this.selection.value!.source.div!.getBoundingClientRect();
|
||||
const selectedValue = this.selection.value;
|
||||
const sourceRect = selectedValue!.source.div!.getBoundingClientRect();
|
||||
|
||||
// calculate relative coordinates based on source and target coorindates of connection
|
||||
const { x1, y1, x2, y2 } = calculateCoordinates(
|
||||
sourceRect,
|
||||
parentBoundingRect,
|
||||
this.selection.value?.info!,
|
||||
this.selection.value!.target,
|
||||
selectedValue?.info!,
|
||||
selectedValue!.target,
|
||||
transformScale
|
||||
);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
|
||||
xStart = selectedValue.sourceOriginal.x;
|
||||
yStart = selectedValue.sourceOriginal.y;
|
||||
xEnd = selectedValue.targetOriginal.x;
|
||||
yEnd = selectedValue.targetOriginal.y;
|
||||
}
|
||||
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
let vx1 = x1;
|
||||
let vy1 = y1;
|
||||
let vx2 = x2;
|
||||
let vy2 = y2;
|
||||
if (this.selection.value && this.selection.value.vertices) {
|
||||
if (selectedValue && selectedValue.vertices) {
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
|
||||
vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1);
|
||||
vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1);
|
||||
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
|
||||
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
|
||||
}
|
||||
if (
|
||||
this.selectedVertexIndex !== undefined &&
|
||||
this.selectedVertexIndex < this.selection.value.vertices.length - 1
|
||||
) {
|
||||
vx2 = this.selection.value.vertices[this.selectedVertexIndex + 1].x * (x2 - x1) + x1;
|
||||
vy2 = this.selection.value.vertices[this.selectedVertexIndex + 1].y * (y2 - y1) + y1;
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length - 1) {
|
||||
vx2 = selectedValue.vertices[this.selectedVertexIndex + 1].x * xDist + xStart;
|
||||
vy2 = selectedValue.vertices[this.selectedVertexIndex + 1].y * yDist + yStart;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,23 +397,24 @@ export class Connections {
|
||||
this.connectionSVGVertex!.style.display = 'none';
|
||||
|
||||
// call onChange here and update appropriate index of connection vertices array
|
||||
const connectionIndex = this.selection.value?.index;
|
||||
const connectionIndex = selectedValue?.index;
|
||||
const vertexIndex = this.selectedVertexIndex;
|
||||
|
||||
if (connectionIndex !== undefined && vertexIndex !== undefined) {
|
||||
const currentSource = this.selection.value!.source;
|
||||
const currentSource = selectedValue!.source;
|
||||
if (currentSource.options.connections) {
|
||||
const currentConnections = [...currentSource.options.connections];
|
||||
if (currentConnections[connectionIndex].vertices) {
|
||||
const currentVertices = [...currentConnections[connectionIndex].vertices!];
|
||||
|
||||
// TODO for vertex removal, clear out originals?
|
||||
if (deleteVertex) {
|
||||
currentVertices.splice(vertexIndex, 1);
|
||||
} else {
|
||||
const currentVertex = { ...currentVertices[vertexIndex] };
|
||||
|
||||
currentVertex.x = (xSnap - x1) / (x2 - x1);
|
||||
currentVertex.y = (ySnap - y1) / (y2 - y1);
|
||||
currentVertex.x = (xSnap - xStart) / xDist;
|
||||
currentVertex.y = (ySnap - yStart) / yDist;
|
||||
|
||||
currentVertices[vertexIndex] = currentVertex;
|
||||
}
|
||||
@@ -447,29 +457,41 @@ export class Connections {
|
||||
this.connectionVertex?.setAttribute('cx', `${x}`);
|
||||
this.connectionVertex?.setAttribute('cy', `${y}`);
|
||||
|
||||
const sourceRect = this.selection.value!.source.div!.getBoundingClientRect();
|
||||
const selectedValue = this.selection.value;
|
||||
const sourceRect = selectedValue!.source.div!.getBoundingClientRect();
|
||||
|
||||
// calculate relative coordinates based on source and target coorindates of connection
|
||||
const { x1, y1, x2, y2 } = calculateCoordinates(
|
||||
sourceRect,
|
||||
parentBoundingRect,
|
||||
this.selection.value?.info!,
|
||||
this.selection.value!.target,
|
||||
selectedValue?.info!,
|
||||
selectedValue!.target,
|
||||
transformScale
|
||||
);
|
||||
|
||||
let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 };
|
||||
if (selectedValue?.sourceOriginal && selectedValue.targetOriginal) {
|
||||
xStart = selectedValue.sourceOriginal.x;
|
||||
yStart = selectedValue.sourceOriginal.y;
|
||||
xEnd = selectedValue.targetOriginal.x;
|
||||
yEnd = selectedValue.targetOriginal.y;
|
||||
}
|
||||
|
||||
const xDist = xEnd - xStart;
|
||||
const yDist = yEnd - yStart;
|
||||
|
||||
let vx1 = x1;
|
||||
let vy1 = y1;
|
||||
let vx2 = x2;
|
||||
let vy2 = y2;
|
||||
if (this.selection.value && this.selection.value.vertices) {
|
||||
if (selectedValue && selectedValue.vertices) {
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) {
|
||||
vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1);
|
||||
vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1);
|
||||
vx1 = selectedValue.vertices[this.selectedVertexIndex - 1].x * xDist + xStart;
|
||||
vy1 = selectedValue.vertices[this.selectedVertexIndex - 1].y * yDist + yStart;
|
||||
}
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < this.selection.value.vertices.length) {
|
||||
vx2 = this.selection.value.vertices[this.selectedVertexIndex].x * (x2 - x1) + x1;
|
||||
vy2 = this.selection.value.vertices[this.selectedVertexIndex].y * (y2 - y1) + y1;
|
||||
if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < selectedValue.vertices.length) {
|
||||
vx2 = selectedValue.vertices[this.selectedVertexIndex].x * xDist + xStart;
|
||||
vy2 = selectedValue.vertices[this.selectedVertexIndex].y * yDist + yStart;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,14 +539,14 @@ export class Connections {
|
||||
this.connectionSVGVertex!.style.display = 'none';
|
||||
|
||||
// call onChange here and insert new vertex at appropriate index of connection vertices array
|
||||
const connectionIndex = this.selection.value?.index;
|
||||
const connectionIndex = selectedValue?.index;
|
||||
const vertexIndex = this.selectedVertexIndex;
|
||||
|
||||
if (connectionIndex !== undefined && vertexIndex !== undefined) {
|
||||
const currentSource = this.selection.value!.source;
|
||||
const currentSource = selectedValue!.source;
|
||||
if (currentSource.options.connections) {
|
||||
const currentConnections = [...currentSource.options.connections];
|
||||
const newVertex = { x: (x - x1) / (x2 - x1), y: (y - y1) / (y2 - y1) };
|
||||
const newVertex = { x: (x - xStart) / xDist, y: (y - yStart) / yDist };
|
||||
if (currentConnections[connectionIndex].vertices) {
|
||||
const currentVertices = [...currentConnections[connectionIndex].vertices!];
|
||||
currentVertices.splice(vertexIndex, 0, newVertex);
|
||||
@@ -541,6 +563,17 @@ export class Connections {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for original state
|
||||
if (
|
||||
!currentConnections[connectionIndex].sourceOriginal ||
|
||||
!currentConnections[connectionIndex].targetOriginal
|
||||
) {
|
||||
currentConnections[connectionIndex] = {
|
||||
...currentConnections[connectionIndex],
|
||||
sourceOriginal: { x: x1, y: y1 },
|
||||
targetOriginal: { x: x2, y: y2 },
|
||||
};
|
||||
}
|
||||
// Update save model
|
||||
currentSource.onChange({ ...currentSource.options, connections: currentConnections });
|
||||
this.updateState();
|
||||
|
||||
@@ -35,7 +35,7 @@ export function InlineEditBody() {
|
||||
const pane = useMemo(() => {
|
||||
const p = activePanel?.panel;
|
||||
const state: InstanceState = instanceState;
|
||||
if (!state || !p) {
|
||||
if (!(state && state.scene) || !p) {
|
||||
return new OptionsPaneCategoryDescriptor({ id: 'root', title: 'root' });
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ composableKinds: PanelCfg: {
|
||||
color?: ui.ColorDimensionConfig
|
||||
size?: ui.ScaleDimensionConfig
|
||||
vertices?: [...ConnectionCoordinates]
|
||||
sourceOriginal?: ConnectionCoordinates
|
||||
targetOriginal?: ConnectionCoordinates
|
||||
} @cuetsy(kind="interface")
|
||||
CanvasElementOptions: {
|
||||
name: string
|
||||
|
||||
@@ -81,8 +81,10 @@ export interface CanvasConnection {
|
||||
path: ConnectionPath;
|
||||
size?: ui.ScaleDimensionConfig;
|
||||
source: ConnectionCoordinates;
|
||||
sourceOriginal?: ConnectionCoordinates;
|
||||
target: ConnectionCoordinates;
|
||||
targetName?: string;
|
||||
targetOriginal?: ConnectionCoordinates;
|
||||
vertices?: Array<ConnectionCoordinates>;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface ConnectionState {
|
||||
target: ElementState;
|
||||
info: CanvasConnection;
|
||||
vertices?: ConnectionCoordinates[];
|
||||
sourceOriginal?: ConnectionCoordinates;
|
||||
targetOriginal?: ConnectionCoordinates;
|
||||
}
|
||||
|
||||
export enum LineStyle {
|
||||
|
||||
@@ -298,6 +298,8 @@ export function getConnections(sceneByName: Map<string, ElementState>) {
|
||||
target,
|
||||
info: c,
|
||||
vertices: c.vertices ?? undefined,
|
||||
sourceOriginal: c.sourceOriginal ?? undefined,
|
||||
targetOriginal: c.targetOriginal ?? undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -352,6 +354,14 @@ export const calculateCoordinates = (
|
||||
}
|
||||
x2 /= transformScale;
|
||||
y2 /= transformScale;
|
||||
|
||||
// TODO look into a better way to avoid division by zero
|
||||
if (x2 - x1 === 0) {
|
||||
x2 += 1;
|
||||
}
|
||||
if (y2 - y1 === 0) {
|
||||
y2 += 1;
|
||||
}
|
||||
return { x1, y1, x2, y2 };
|
||||
};
|
||||
|
||||
@@ -365,9 +375,11 @@ export const calculateAbsoluteCoords = (
|
||||
x2: number,
|
||||
y2: number,
|
||||
valueX: number,
|
||||
valueY: number
|
||||
valueY: number,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) => {
|
||||
return { x: valueX * (x2 - x1) + x1, y: valueY * (y2 - y1) + y1 };
|
||||
return { x: valueX * deltaX + x1, y: valueY * deltaY + y1 };
|
||||
};
|
||||
|
||||
// Calculate angle between two points and return angle in radians
|
||||
|
||||
Reference in New Issue
Block a user