task(CAD-3): box selection - window/crossing by drag direction, preview rectangle, shift-additive, defensive state hygiene
This commit is contained in:
@@ -178,6 +178,71 @@ function applyVertexDrag(
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select).
|
||||||
|
// Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG
|
||||||
|
// in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung
|
||||||
|
// reicht). Leerer down startet Box-Drag statt Selektion zu leeren.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */
|
||||||
|
export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' {
|
||||||
|
return to.x >= from.x ? 'window' : 'crossing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selektiert alle Elemente, die die Box erfuellen.
|
||||||
|
* window: BBox des Elements VOLLSTAENDIG in der Box.
|
||||||
|
* crossing: BBox des Elements ueberschneidet/beruehrt die Box.
|
||||||
|
*/
|
||||||
|
export function boxSelectElements(
|
||||||
|
elements: CADElement[],
|
||||||
|
box: { x1: number; y1: number; x2: number; y2: number },
|
||||||
|
mode: 'window' | 'crossing',
|
||||||
|
): string[] {
|
||||||
|
const minX = Math.min(box.x1, box.x2);
|
||||||
|
const maxX = Math.max(box.x1, box.x2);
|
||||||
|
const minY = Math.min(box.y1, box.y2);
|
||||||
|
const maxY = Math.max(box.y1, box.y2);
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (const el of elements) {
|
||||||
|
const b = elementBBox(el);
|
||||||
|
if (!b) continue;
|
||||||
|
if (mode === 'window') {
|
||||||
|
if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) {
|
||||||
|
hits.push(el.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// crossing: Ueberschneidung (Beruehrung inklusive)
|
||||||
|
if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) {
|
||||||
|
hits.push(el.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */
|
||||||
|
function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||||
|
const props = el.properties as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
props.x1 !== undefined && props.y1 !== undefined &&
|
||||||
|
props.x2 !== undefined && props.y2 !== undefined
|
||||||
|
) {
|
||||||
|
const x1 = props.x1 as number, y1 = props.y1 as number;
|
||||||
|
const x2 = props.x2 as number, y2 = props.y2 as number;
|
||||||
|
return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) };
|
||||||
|
}
|
||||||
|
const w = el.width ?? 0, h = el.height ?? 0;
|
||||||
|
return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Box-Drag-Sitzung (Task CAD-3). */
|
||||||
|
let boxDrag: { start: Pt; shift: boolean } | null = null;
|
||||||
|
|
||||||
|
/** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */
|
||||||
|
const BOX_MIN_SIZE = 3;
|
||||||
|
|
||||||
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
||||||
let vertexDrag: {
|
let vertexDrag: {
|
||||||
el: CADElement;
|
el: CADElement;
|
||||||
@@ -200,6 +265,9 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
const bridge = (ctx as FullToolContext).selection;
|
const bridge = (ctx as FullToolContext).selection;
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
if (!bridge) return;
|
if (!bridge) return;
|
||||||
|
// CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene,
|
||||||
|
// schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt.
|
||||||
|
boxDrag = null;
|
||||||
const additive = e.shift;
|
const additive = e.shift;
|
||||||
const subtractive = e.ctrl;
|
const subtractive = e.ctrl;
|
||||||
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
||||||
@@ -243,7 +311,10 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
if (!additive && !subtractive) bridge.setIds([]);
|
// ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ──
|
||||||
|
// (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug)
|
||||||
|
boxDrag = { start: e.world, shift: e.shift };
|
||||||
|
ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ids = new Set(bridge.getIds());
|
let ids = new Set(bridge.getIds());
|
||||||
@@ -258,6 +329,31 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
move(e, ctx) {
|
move(e, ctx) {
|
||||||
|
// ── Task CAD-3: Box-Select-Preview ──
|
||||||
|
if (boxDrag) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const x = Math.min(boxDrag.start.x, e.world.x);
|
||||||
|
const y = Math.min(boxDrag.start.y, e.world.y);
|
||||||
|
const w = Math.abs(e.world.x - boxDrag.start.x);
|
||||||
|
const h = Math.abs(e.world.y - boxDrag.start.y);
|
||||||
|
const mode = boxModeFor(boxDrag.start, e.world);
|
||||||
|
c.setPreview?.({
|
||||||
|
id: '__preview_boxselect__',
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'layer-0',
|
||||||
|
x: x + w / 2,
|
||||||
|
y: y + h / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
properties: {
|
||||||
|
stroke: mode === 'window' ? '#4a90d9' : '#22c55e',
|
||||||
|
strokeWidth: 1,
|
||||||
|
fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)',
|
||||||
|
boxSelectMode: mode,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
||||||
if (vertexDrag) {
|
if (vertexDrag) {
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
@@ -308,6 +404,40 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
up(e, ctx) {
|
up(e, ctx) {
|
||||||
|
// ── Task CAD-3: Box-Select-Abschluss ──
|
||||||
|
if (boxDrag) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
const start = boxDrag.start;
|
||||||
|
const shift = boxDrag.shift || e.shift;
|
||||||
|
boxDrag = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
const w = Math.abs(e.world.x - start.x);
|
||||||
|
const h = Math.abs(e.world.y - start.y);
|
||||||
|
// Winzige Box = Klick ohne Zug → Legacy: Selektion leeren
|
||||||
|
if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) {
|
||||||
|
if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]);
|
||||||
|
ctx.setStatus('Selektion geleert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = boxModeFor(start, e.world);
|
||||||
|
const all = c.doc ? c.doc.getAllElements() : [];
|
||||||
|
const hits = boxSelectElements(all, {
|
||||||
|
x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y,
|
||||||
|
}, mode);
|
||||||
|
const bridge = (ctx as FullToolContext).selection;
|
||||||
|
if (!bridge) return;
|
||||||
|
if (shift) {
|
||||||
|
const merged = new Set([...bridge.getIds(), ...hits]);
|
||||||
|
bridge.setIds(Array.from(merged));
|
||||||
|
} else {
|
||||||
|
bridge.setIds(hits);
|
||||||
|
}
|
||||||
|
const count = shift
|
||||||
|
? new Set([...(bridge.getIds())]).size
|
||||||
|
: hits.length;
|
||||||
|
ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
||||||
if (vertexDrag) {
|
if (vertexDrag) {
|
||||||
const c = ctx as FullToolContext;
|
const c = ctx as FullToolContext;
|
||||||
@@ -379,6 +509,7 @@ export const selectTool: ToolExtensionV2 = {
|
|||||||
cancel(ctx) {
|
cancel(ctx) {
|
||||||
handleDrag = null;
|
handleDrag = null;
|
||||||
vertexDrag = null;
|
vertexDrag = null;
|
||||||
|
boxDrag = null;
|
||||||
ctx.setPreview?.(null);
|
ctx.setPreview?.(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-3 - Fenster-/Kreuz-Selektion (Box-Select).
|
||||||
|
*
|
||||||
|
* boxSelectElements: reine Funktion. Links-nach-rechts ziehen =
|
||||||
|
* 'window' (Element muss VOLLSTAENDIG in der Box liegen),
|
||||||
|
* rechts-nach-links = 'crossing' (Element darf die Box beruehren).
|
||||||
|
* Im selectTool: Leerer down startet Box-Drag, up selektiert.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { boxSelectElements, boxModeFor } from '../src/plugins/builtin/core-modify';
|
||||||
|
import { selectTool } from '../src/plugins/builtin/core-modify';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx(elements: CADElement[]) {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
for (const el of elements) doc.addElement(el);
|
||||||
|
const selectedIds = new Set<string>();
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
selection: {
|
||||||
|
getIds: () => Array.from(selectedIds),
|
||||||
|
setIds: (ids: string[]) => {
|
||||||
|
selectedIds.clear();
|
||||||
|
for (const id of ids) selectedIds.add(id);
|
||||||
|
},
|
||||||
|
hitTest: (x: number, y: number, _tol: number) => {
|
||||||
|
for (const el of elements) {
|
||||||
|
const p = el.properties as Record<string, unknown>;
|
||||||
|
if (p.x1 !== undefined) {
|
||||||
|
const minX = Math.min(p.x1 as number, p.x2 as number);
|
||||||
|
const maxX = Math.max(p.x1 as number, p.x2 as number);
|
||||||
|
const minY = Math.min(p.y1 as number, p.y2 as number);
|
||||||
|
const maxY = Math.max(p.y1 as number, p.y2 as number);
|
||||||
|
if (x >= minX - 5 && x <= maxX + 5 && y >= minY - 5 && y <= maxY + 5) return el;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
|
||||||
|
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
|
||||||
|
) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, doc, selectedIds, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||||
|
return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rect(id: string, x: number, y: number, w: number, h: number): CADElement {
|
||||||
|
return { id, type: 'rect', layerId: 'l', x, y, width: w, height: h, properties: {} } as unknown as CADElement;
|
||||||
|
}
|
||||||
|
function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'line', layerId: 'l',
|
||||||
|
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
|
||||||
|
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||||
|
properties: { x1, y1, x2, y2 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── boxModeFor: Richtung bestimmt Modus ──────────────────────
|
||||||
|
|
||||||
|
describe('CAD-3: boxModeFor', () => {
|
||||||
|
it('Links-nach-rechts = window', () => {
|
||||||
|
expect(boxModeFor({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe('window');
|
||||||
|
});
|
||||||
|
it('Rechts-nach-links = crossing', () => {
|
||||||
|
expect(boxModeFor({ x: 100, y: 0 }, { x: 0, y: 100 })).toBe('crossing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── boxSelectElements: window (vollstaendig enthalten) ─────
|
||||||
|
|
||||||
|
describe('CAD-3: window-Selektion (vollstaendig enthalten)', () => {
|
||||||
|
it('Element komplett in Box wird selektiert', () => {
|
||||||
|
const els = [rect('r1', 50, 50, 20, 20)];
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
const hit = boxSelectElements(els, box, 'window');
|
||||||
|
expect(hit).toContain('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element, das die Box-Kante durchstoesst, wird NICHT selektiert (window)', () => {
|
||||||
|
// rect 90..110, Box 0..100: ragt raus
|
||||||
|
const els = [rect('r1', 100, 50, 20, 20)];
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
const hit = boxSelectElements(els, box, 'window');
|
||||||
|
expect(hit).not.toContain('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Line mit beiden Endpunkten in Box wird selektiert', () => {
|
||||||
|
const els = [line('l1', 10, 10, 80, 80)];
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
expect(boxSelectElements(els, box, 'window')).toContain('l1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── boxSelectElements: crossing (Beruehrung reicht) ─────────
|
||||||
|
|
||||||
|
describe('CAD-3: crossing-Selektion (Beruehrung reicht)', () => {
|
||||||
|
it('Element, das die Box nur teilweise durchstoesst, WIRD selektiert', () => {
|
||||||
|
const els = [rect('r1', 100, 50, 20, 20)]; // 90..110
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
const hit = boxSelectElements(els, box, 'crossing');
|
||||||
|
expect(hit).toContain('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element ausserhalb der Box wird nicht selektiert', () => {
|
||||||
|
const els = [rect('r1', 500, 500, 20, 20)];
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
expect(boxSelectElements(els, box, 'crossing')).not.toContain('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element, das die Box nur beruehrt (Kante = Kante), wird selektiert', () => {
|
||||||
|
// rect endet exakt bei x=100 (Kante = Box-Kante)
|
||||||
|
const els = [rect('r1', 95, 50, 10, 20)]; // 90..100
|
||||||
|
const box = { x1: 0, y1: 0, x2: 100, y2: 100 };
|
||||||
|
expect(boxSelectElements(els, box, 'crossing')).toContain('r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tool-Integration: Box-Drag im selectTool ────────────────
|
||||||
|
|
||||||
|
describe('CAD-3: selectTool Box-Drag', () => {
|
||||||
|
it('Leerer down + move + up fuehrt Box-Selektion aus (window: links nach rechts)', () => {
|
||||||
|
const inside = rect('r1', 50, 50, 20, 20);
|
||||||
|
const outside = rect('r2', 500, 500, 20, 20);
|
||||||
|
const { ctx, selectedIds, previews } = mkCtx([inside, outside]);
|
||||||
|
|
||||||
|
// down auf Leerraum (kein Element getroffen)
|
||||||
|
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
// move zieht Box auf
|
||||||
|
selectTool.handlers.move!(pe(100, 100), ctx);
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1); // Box-Preview
|
||||||
|
// up schliesst ab
|
||||||
|
selectTool.handlers.up!(pe(100, 100), ctx);
|
||||||
|
|
||||||
|
expect(selectedIds.has('r1')).toBe(true);
|
||||||
|
expect(selectedIds.has('r2')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick OHNE Zug (winzige Box) leert die Selektion (Legacy-Verhalten bleibt)', () => {
|
||||||
|
const inside = rect('r1', 50, 50, 20, 20);
|
||||||
|
const { ctx, selectedIds } = mkCtx([inside]);
|
||||||
|
selectedIds.add('r1');
|
||||||
|
// down + up am selben Punkt = kein Zug
|
||||||
|
selectTool.handlers.down!(pe(300, 300), ctx);
|
||||||
|
selectTool.handlers.up!(pe(300, 300), ctx);
|
||||||
|
expect(Array.from(selectedIds)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift + Box-Select addiert zur bestehenden Selektion', () => {
|
||||||
|
const a = rect('a', 50, 50, 20, 20);
|
||||||
|
const b = rect('b', 200, 50, 20, 20);
|
||||||
|
const { ctx, selectedIds } = mkCtx([a, b]);
|
||||||
|
selectedIds.add('a'); // schon selektiert
|
||||||
|
// Shift-Box um b (Shift an down UND up, wie der echte Dispatcher)
|
||||||
|
selectTool.handlers.down!(pe(150, 0, { shift: true }), ctx);
|
||||||
|
selectTool.handlers.up!(pe(250, 100, { shift: true }), ctx);
|
||||||
|
expect(selectedIds.has('a')).toBe(true);
|
||||||
|
expect(selectedIds.has('b')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Status meldet Anzahl und Modus', () => {
|
||||||
|
const els = [rect('r1', 50, 50, 20, 20), rect('r2', 60, 60, 20, 20)];
|
||||||
|
const { ctx, statuses } = mkCtx(els);
|
||||||
|
selectTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
selectTool.handlers.up!(pe(100, 100), ctx);
|
||||||
|
const last = statuses[statuses.length - 1];
|
||||||
|
expect(last).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Box-Drag startet NICHT wenn ein Element getroffen wurde', () => {
|
||||||
|
const el = rect('r1', 50, 50, 20, 20);
|
||||||
|
const { ctx, selectedIds } = mkCtx([el]);
|
||||||
|
// down AUF das Element: normale Einzelselektion, kein Box-Start
|
||||||
|
selectTool.handlers.down!(pe(50, 50), ctx);
|
||||||
|
expect(selectedIds.has('r1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -807,6 +807,8 @@ describe('A3 pilot: select tool', () => {
|
|||||||
selectTool.handlers.down!(pe(10, 10), ctx);
|
selectTool.handlers.down!(pe(10, 10), ctx);
|
||||||
expect(getIds()).toEqual(['a']);
|
expect(getIds()).toEqual(['a']);
|
||||||
selectTool.handlers.down!(pe(999, 999), ctx);
|
selectTool.handlers.down!(pe(999, 999), ctx);
|
||||||
|
// CAD-3: Leerklick leert erst bei up (Klick ohne Zug) — Box-Select-Follow-up
|
||||||
|
selectTool.handlers.up!(pe(999, 999), ctx);
|
||||||
expect(getIds()).toEqual([]);
|
expect(getIds()).toEqual([]);
|
||||||
|
|
||||||
expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);
|
expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user