mirror of
https://github.com/smartxworks/sunmao-ui.git
synced 2024-11-21 03:15:49 +08:00
Merge pull request #412 from webzard-io/mask/scroll-intersection
refactor editor mask
This commit is contained in:
commit
3a32c221e1
@ -1,4 +1,4 @@
|
||||
import {whereIsMouse} from '../src/components/EditorMaskWrapper'
|
||||
import {whereIsMouse} from '../src/components/EditorMaskWrapper/EditorMaskManager'
|
||||
|
||||
const mockRects = JSON.parse(
|
||||
'{"button19":{"x":307,"y":85,"width":45.046875,"height":40,"top":85,"right":352.046875,"bottom":125,"left":307},"button15":{"x":393.046875,"y":102,"width":40,"height":40,"top":102,"right":433.046875,"bottom":142,"left":393.046875},"input18":{"x":457.046875,"y":102,"width":34,"height":40,"top":102,"right":491.046875,"bottom":142,"left":457.046875},"vstack20":{"x":515.046875,"y":102,"width":55.359375,"height":40,"top":102,"right":570.40625,"bottom":142,"left":515.046875},"hstack14":{"x":376.046875,"y":85,"width":211.359375,"height":74,"top":85,"right":587.40625,"bottom":159,"left":376.046875},"input16":{"x":376.046875,"y":199,"width":211.359375,"height":40,"top":199,"right":587.40625,"bottom":239,"left":376.046875},"stack13":{"x":376.046875,"y":85,"width":211.359375,"height":154,"top":85,"right":587.40625,"bottom":239,"left":376.046875},"button17":{"x":611.40625,"y":85,"width":45.046875,"height":40,"top":85,"right":656.453125,"bottom":125,"left":611.40625},"button23":{"x":714.453125,"y":119,"width":61.359375,"height":40,"top":119,"right":775.8125,"bottom":159,"left":714.453125},"button24":{"x":799.8125,"y":119,"width":61.359375,"height":40,"top":119,"right":861.171875,"bottom":159,"left":799.8125},"hstack22":{"x":697.453125,"y":102,"width":298.546875,"height":74,"top":102,"right":996,"bottom":176,"left":697.453125},"vstack21":{"x":680.453125,"y":85,"width":332.546875,"height":108,"top":85,"right":1013,"bottom":193,"left":680.453125},"hstack12":{"x":290,"y":68,"width":740,"height":188,"top":68,"right":1030,"bottom":256,"left":290}}'
|
||||
|
@ -160,10 +160,9 @@ export const Editor: React.FC<Props> = observer(
|
||||
flexDirection="column"
|
||||
width="full"
|
||||
height="full"
|
||||
overflow="auto"
|
||||
padding="20px"
|
||||
transform={`scale(${scale / 100})`}
|
||||
position="relative"
|
||||
overflow='hidden'
|
||||
>
|
||||
<EditorMaskWrapper services={services}>
|
||||
{appComponent}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useEffect, useRef } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { EditorServices } from '../../types';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { DropSlotMask } from './DropSlotMask';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { DIALOG_CONTAINER_ID } from '@sunmao-ui/runtime';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { observer, useLocalStore } from 'mobx-react-lite';
|
||||
import { DropSlotMask } from './DropSlotMask';
|
||||
import { EditorMaskManager } from './EditorMaskManager';
|
||||
import { EditorServices } from '../../types';
|
||||
|
||||
const outlineMaskTextStyle = css`
|
||||
position: absolute;
|
||||
@ -51,153 +51,30 @@ type Props = {
|
||||
export const EditorMask: React.FC<Props> = observer((props: Props) => {
|
||||
const { services, mousePosition, wrapperRef, hoverComponentIdRef, dragOverSlotRef } =
|
||||
props;
|
||||
const { eventBus, editorStore } = services;
|
||||
const { selectedComponentId, eleMap, isDraggingNewComponent } = editorStore;
|
||||
const { editorStore } = services;
|
||||
const { isDraggingNewComponent } = editorStore;
|
||||
const maskContainerRef = useRef<HTMLDivElement>(null);
|
||||
const maskContainerRect = useRef<DOMRect>();
|
||||
const [coordinates, setCoordinates] = useState<Record<string, DOMRect>>({});
|
||||
const [coordinatesOffset, setCoordinatedOffset] = useState<[number, number]>([0, 0]);
|
||||
|
||||
// establish the coordinateSystem by getting all the rect of elements,
|
||||
// and recording the current scroll Offset
|
||||
// and the updating maskContainerRect, because maskContainer shares the same coordinates with app
|
||||
const updateCoordinateSystem = useCallback(
|
||||
(eleMap: Map<string, HTMLElement>) => {
|
||||
function isChild(child: HTMLElement, parent: HTMLElement) {
|
||||
let curr = child;
|
||||
while (curr.parentElement && !curr.parentElement.isSameNode(wrapperRef.current)) {
|
||||
if (curr.parentElement.isSameNode(parent)) {
|
||||
return true;
|
||||
}
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!wrapperRef.current) return;
|
||||
const _rects: Record<string, DOMRect> = {};
|
||||
const modalContainerEle = document.getElementById(DIALOG_CONTAINER_ID)!;
|
||||
const modalEleMap = new Map<string, HTMLElement>();
|
||||
// detect if there are components in modal
|
||||
for (const id of eleMap.keys()) {
|
||||
const ele = eleMap.get(id)!;
|
||||
if (isChild(ele, modalContainerEle)) {
|
||||
modalEleMap.set(id, ele);
|
||||
}
|
||||
}
|
||||
|
||||
const foregroundEleMap = modalEleMap.size > 0 ? modalEleMap : eleMap;
|
||||
|
||||
for (const id of foregroundEleMap.keys()) {
|
||||
const ele = eleMap.get(id)!;
|
||||
const rect = ele.getBoundingClientRect();
|
||||
_rects[id] = rect;
|
||||
}
|
||||
maskContainerRect.current = maskContainerRef.current?.getBoundingClientRect();
|
||||
setCoordinates(_rects);
|
||||
setCoordinatedOffset([wrapperRef.current.scrollLeft, wrapperRef.current.scrollTop]);
|
||||
},
|
||||
[wrapperRef]
|
||||
const store = useLocalStore(
|
||||
() =>
|
||||
new EditorMaskManager(services, wrapperRef, maskContainerRef, hoverComponentIdRef)
|
||||
);
|
||||
|
||||
const resizeObserver = useMemo(() => {
|
||||
const debouncedUpdateRects = debounce(updateCoordinateSystem, 50);
|
||||
return new ResizeObserver(() => {
|
||||
debouncedUpdateRects(eleMap);
|
||||
});
|
||||
}, [eleMap, updateCoordinateSystem]);
|
||||
|
||||
const observeResize = useCallback(
|
||||
(eleMap: Map<string, HTMLElement>) => {
|
||||
for (const id of eleMap.keys()) {
|
||||
const ele = eleMap.get(id);
|
||||
if (ele) {
|
||||
resizeObserver.observe(ele);
|
||||
}
|
||||
}
|
||||
},
|
||||
[resizeObserver]
|
||||
);
|
||||
|
||||
// because this useEffect would run after sunmao didMount hook, so it cannot subscribe the first HTMLElementsUpdated event
|
||||
// we should call the callback function after first render
|
||||
const { hoverComponentId, hoverMaskPosition, selectedMaskPosition } = store;
|
||||
useEffect(() => {
|
||||
observeResize(eleMap);
|
||||
updateCoordinateSystem(eleMap);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.on('HTMLElementsUpdated', () => {
|
||||
observeResize(eleMap);
|
||||
updateCoordinateSystem(eleMap);
|
||||
});
|
||||
}, [eleMap, eventBus, observeResize, updateCoordinateSystem]);
|
||||
|
||||
// listen elements resize and update coordinates
|
||||
useEffect(() => {
|
||||
observeResize(eleMap);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [eleMap, observeResize, resizeObserver]);
|
||||
|
||||
const hoverComponentId = useMemo(() => {
|
||||
const where = whereIsMouse(
|
||||
mousePosition[0] - coordinatesOffset[0],
|
||||
mousePosition[1] - coordinatesOffset[1],
|
||||
coordinates
|
||||
);
|
||||
return where;
|
||||
}, [coordinatesOffset, mousePosition, coordinates]);
|
||||
store.setMousePosition(mousePosition);
|
||||
}, [mousePosition, store]);
|
||||
|
||||
useEffect(() => {
|
||||
hoverComponentIdRef.current = hoverComponentId;
|
||||
}, [hoverComponentIdRef, hoverComponentId]);
|
||||
}, [hoverComponentId, hoverComponentIdRef]);
|
||||
|
||||
const getMaskPosition = useCallback(
|
||||
(componentId: string) => {
|
||||
const rect = coordinates[componentId];
|
||||
const padding = 4;
|
||||
if (!maskContainerRect.current || !wrapperRef.current || !rect) return;
|
||||
return {
|
||||
id: componentId,
|
||||
style: {
|
||||
top: rect.top - maskContainerRect.current.top - padding,
|
||||
left: rect.left - maskContainerRect.current.left - padding,
|
||||
height: rect.height + padding * 2,
|
||||
width: rect.width + padding * 2,
|
||||
},
|
||||
};
|
||||
},
|
||||
[coordinates, wrapperRef]
|
||||
);
|
||||
|
||||
const hoverMaskPosition = useMemo(() => {
|
||||
if (!maskContainerRect.current || !hoverComponentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getMaskPosition(hoverComponentId);
|
||||
}, [hoverComponentId, getMaskPosition]);
|
||||
|
||||
const selectedMaskPosition = useMemo(() => {
|
||||
if (!maskContainerRect.current || !selectedComponentId) return undefined;
|
||||
|
||||
return getMaskPosition(selectedComponentId);
|
||||
}, [selectedComponentId, getMaskPosition]);
|
||||
useEffect(() => {
|
||||
store.modalContainerEle = document.getElementById(DIALOG_CONTAINER_ID);
|
||||
});
|
||||
|
||||
const hoverMask = hoverMaskPosition ? (
|
||||
<Box
|
||||
className={outlineMaskStyle}
|
||||
borderColor="gray.700"
|
||||
zIndex="1"
|
||||
style={hoverMaskPosition.style}
|
||||
>
|
||||
<Text className={outlineMaskTextStyle} background="gray.700">
|
||||
{hoverMaskPosition.id}
|
||||
</Text>
|
||||
</Box>
|
||||
<HoverMask style={hoverMaskPosition.style} id={hoverMaskPosition.id} />
|
||||
) : undefined;
|
||||
|
||||
const dragMask = hoverMaskPosition ? (
|
||||
@ -212,16 +89,7 @@ export const EditorMask: React.FC<Props> = observer((props: Props) => {
|
||||
) : undefined;
|
||||
|
||||
const selectMask = selectedMaskPosition ? (
|
||||
<Box
|
||||
className={outlineMaskStyle}
|
||||
borderColor="blue.500"
|
||||
zIndex="0"
|
||||
style={selectedMaskPosition.style}
|
||||
>
|
||||
<Text className={outlineMaskTextStyle} background="blue.500">
|
||||
{selectedMaskPosition.id}
|
||||
</Text>
|
||||
</Box>
|
||||
<SelectMask style={selectedMaskPosition.style} id={selectedMaskPosition.id} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
@ -242,29 +110,37 @@ export const EditorMask: React.FC<Props> = observer((props: Props) => {
|
||||
);
|
||||
});
|
||||
|
||||
export function whereIsMouse(
|
||||
left: number,
|
||||
top: number,
|
||||
rects: Record<string, DOMRect>
|
||||
): string {
|
||||
let nearest = {
|
||||
id: '',
|
||||
sum: 0,
|
||||
};
|
||||
for (const id in rects) {
|
||||
const rect = rects[id];
|
||||
if (
|
||||
top < rect.top ||
|
||||
left < rect.left ||
|
||||
top > rect.top + rect.height ||
|
||||
left > rect.left + rect.width
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const sum = rect.top + rect.left;
|
||||
if (sum > nearest.sum) {
|
||||
nearest = { id, sum };
|
||||
}
|
||||
}
|
||||
return nearest.id;
|
||||
}
|
||||
type MaskProps = {
|
||||
style: CSSProperties;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const HoverMask: React.FC<MaskProps> = (props: MaskProps) => {
|
||||
return (
|
||||
<Box
|
||||
className={outlineMaskStyle}
|
||||
borderColor="gray.700"
|
||||
zIndex="1"
|
||||
style={props.style}
|
||||
>
|
||||
<Text className={outlineMaskTextStyle} background="gray.700">
|
||||
{props.id}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectMask: React.FC<MaskProps> = (props: MaskProps) => {
|
||||
return (
|
||||
<Box
|
||||
className={outlineMaskStyle}
|
||||
borderColor="blue.500"
|
||||
zIndex="0"
|
||||
style={props.style}
|
||||
>
|
||||
<Text className={outlineMaskTextStyle} background="blue.500">
|
||||
{props.id}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,239 @@
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
import React from 'react';
|
||||
import { EditorServices } from '../../types';
|
||||
|
||||
export class EditorMaskManager {
|
||||
// observable: current mouse position
|
||||
mousePosition: [number, number] = [0, 0];
|
||||
// observable: rects of all foreground components
|
||||
rects: Record<string, DOMRect> = {};
|
||||
// observable: rect of mask container
|
||||
maskContainerRect: DOMRect | null = null;
|
||||
// observable: the coordinate system offset, it is almost equal to the scroll value of maskWrapper
|
||||
systemOffset: [number, number] = [0, 0];
|
||||
modalContainerEle: HTMLElement | null = null;
|
||||
// visible status of all components.
|
||||
private visibleMap = new Map<Element, boolean>();
|
||||
private resizeObserver: ResizeObserver;
|
||||
private intersectionObserver: IntersectionObserver;
|
||||
private MaskPadding = 4;
|
||||
|
||||
get hoverComponentId() {
|
||||
const where = whereIsMouse(
|
||||
this.mousePosition[0] - this.systemOffset[0],
|
||||
this.mousePosition[1] - this.systemOffset[1],
|
||||
this.rects
|
||||
);
|
||||
return where;
|
||||
}
|
||||
|
||||
get hoverMaskPosition() {
|
||||
return this.getMaskPosition(this.hoverComponentId);
|
||||
}
|
||||
|
||||
get selectedMaskPosition() {
|
||||
return this.getMaskPosition(this.services.editorStore.selectedComponentId);
|
||||
}
|
||||
|
||||
constructor(
|
||||
public services: EditorServices,
|
||||
public wrapperRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
public maskContainerRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
public hoverComponentIdRef: React.MutableRefObject<string>
|
||||
) {
|
||||
makeObservable(this, {
|
||||
rects: observable.shallow,
|
||||
mousePosition: observable.ref,
|
||||
maskContainerRect: observable.ref,
|
||||
systemOffset: observable.ref,
|
||||
setRects: action,
|
||||
setMousePosition: action,
|
||||
setMaskContainerRect: action,
|
||||
setSystemOffset: action,
|
||||
hoverComponentId: computed,
|
||||
hoverMaskPosition: computed,
|
||||
selectedMaskPosition: computed,
|
||||
});
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.refreshSystem();
|
||||
});
|
||||
|
||||
this.intersectionObserver = this.initIntersectionObserver();
|
||||
|
||||
this.observeIntersection();
|
||||
this.observeResize();
|
||||
this.observeEvents();
|
||||
}
|
||||
|
||||
private initIntersectionObserver(): IntersectionObserver {
|
||||
const options = {
|
||||
root: document.getElementById('editor-mask-wrapper'),
|
||||
rootMargin: '0px',
|
||||
threshold: buildThresholdList(),
|
||||
};
|
||||
|
||||
return new IntersectionObserver(entries => {
|
||||
// Update visibleMap.
|
||||
// Every time intersection observer is triggered, some components' visibility must have changed.
|
||||
entries.forEach(e => {
|
||||
this.visibleMap.set(e.target, e.isIntersecting);
|
||||
});
|
||||
// the coordinate system need to be refresh
|
||||
this.refreshSystem();
|
||||
}, options);
|
||||
}
|
||||
|
||||
// listen resize events of dom elements
|
||||
private observeResize() {
|
||||
this.resizeObserver.disconnect();
|
||||
this.eleMap.forEach(ele => {
|
||||
this.resizeObserver.observe(ele);
|
||||
});
|
||||
}
|
||||
|
||||
private observeIntersection() {
|
||||
this.intersectionObserver.disconnect();
|
||||
this.eleMap.forEach(ele => {
|
||||
this.intersectionObserver.observe(ele);
|
||||
});
|
||||
}
|
||||
|
||||
private observeEvents() {
|
||||
// listen to the DOM elements' mount and unmount events
|
||||
// TODO: This is not very accurate, because sunmao runtime 'didDOMUpdate' hook is not accurate.
|
||||
// We will refactor the 'didDOMUpdate' hook with components' life cycle in the future.
|
||||
this.services.eventBus.on('HTMLElementsUpdated', () => {
|
||||
this.refreshSystem();
|
||||
this.observeIntersection();
|
||||
this.observeResize();
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the whole coordinate system.
|
||||
// Coordinate system is made up of: reacts, maskContainerRect and systemOffset.
|
||||
private refreshSystem() {
|
||||
this.updateEleRects();
|
||||
if (this.maskContainerEle) {
|
||||
this.setMaskContainerRect(this.maskContainerEle.getBoundingClientRect());
|
||||
}
|
||||
if (this.wrapperEle) {
|
||||
this.setSystemOffset([this.wrapperEle.scrollLeft, this.wrapperEle.scrollTop]);
|
||||
}
|
||||
}
|
||||
|
||||
private updateEleRects() {
|
||||
const _rects: Record<string, DOMRect> = {};
|
||||
const modalEleMap = new Map<string, HTMLElement>();
|
||||
// detect if there are components in modal
|
||||
for (const id of this.eleMap.keys()) {
|
||||
const ele = this.eleMap.get(id)!;
|
||||
if (this.isChild(ele, this.modalContainerEle!)) {
|
||||
modalEleMap.set(id, ele);
|
||||
}
|
||||
}
|
||||
|
||||
const foregroundEleMap = modalEleMap.size > 0 ? modalEleMap : this.eleMap;
|
||||
|
||||
foregroundEleMap.forEach((ele, id) => {
|
||||
if (this.visibleMap.get(ele)) {
|
||||
const rect = ele.getBoundingClientRect();
|
||||
_rects[id] = rect;
|
||||
}
|
||||
});
|
||||
this.setRects(_rects);
|
||||
}
|
||||
|
||||
private isChild(child: HTMLElement, parent: HTMLElement) {
|
||||
let curr = child;
|
||||
while (curr.parentElement && !curr.parentElement.isSameNode(this.wrapperEle)) {
|
||||
if (curr.parentElement.isSameNode(parent)) {
|
||||
return true;
|
||||
}
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getMaskPosition(id: string) {
|
||||
const rect = this.rects[id];
|
||||
if (!this.maskContainerRect || !rect) return null;
|
||||
return {
|
||||
id,
|
||||
style: {
|
||||
top: rect.top - this.maskContainerRect.top - this.MaskPadding,
|
||||
left: rect.left - this.maskContainerRect.left - this.MaskPadding,
|
||||
height: rect.height + this.MaskPadding * 2,
|
||||
width: rect.width + this.MaskPadding * 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setMousePosition(val: [number, number]) {
|
||||
this.mousePosition = val;
|
||||
}
|
||||
|
||||
setSystemOffset(systemOffset: [number, number]) {
|
||||
this.systemOffset = systemOffset;
|
||||
}
|
||||
|
||||
setRects(rects: Record<string, DOMRect>) {
|
||||
this.rects = rects;
|
||||
}
|
||||
|
||||
setMaskContainerRect(maskContainerRect: DOMRect) {
|
||||
this.maskContainerRect = maskContainerRect;
|
||||
}
|
||||
|
||||
private get eleMap() {
|
||||
return this.services.editorStore.eleMap;
|
||||
}
|
||||
|
||||
private get wrapperEle() {
|
||||
return this.wrapperRef.current;
|
||||
}
|
||||
|
||||
private get maskContainerEle() {
|
||||
return this.maskContainerRef.current;
|
||||
}
|
||||
}
|
||||
|
||||
function buildThresholdList() {
|
||||
const thresholds: number[] = [];
|
||||
const numSteps = 20;
|
||||
|
||||
for (let i = 1.0; i <= numSteps; i++) {
|
||||
const ratio = i / numSteps;
|
||||
thresholds.push(ratio);
|
||||
}
|
||||
|
||||
thresholds.push(0);
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
export function whereIsMouse(
|
||||
left: number,
|
||||
top: number,
|
||||
rects: Record<string, DOMRect>
|
||||
): string {
|
||||
let nearest = {
|
||||
id: '',
|
||||
sum: Infinity,
|
||||
};
|
||||
for (const id in rects) {
|
||||
const rect = rects[id];
|
||||
if (
|
||||
top < rect.top ||
|
||||
left < rect.left ||
|
||||
top > rect.top + rect.height ||
|
||||
left > rect.left + rect.width
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const sum = top - rect.top + (left - rect.left);
|
||||
if (sum < nearest.sum) {
|
||||
nearest = { id, sum };
|
||||
}
|
||||
}
|
||||
return nearest.id;
|
||||
}
|
@ -74,17 +74,19 @@ export const EditorMaskWrapper: React.FC<Props> = observer(props => {
|
||||
<Box
|
||||
id="editor-mask-wrapper"
|
||||
width="full"
|
||||
height="0"
|
||||
flex="1"
|
||||
overflow="visible"
|
||||
height="full"
|
||||
overflow="auto"
|
||||
position="relative"
|
||||
padding="20px"
|
||||
// some components stop click event propagation, so here we should capture onClick
|
||||
onClickCapture={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onScroll={onScroll}
|
||||
// here we use capture to detect all the components and their children's scroll event
|
||||
// because every scroll event may change their children's location in coordinates system
|
||||
onScrollCapture={onScroll}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{children}
|
||||
|
@ -1,2 +1 @@
|
||||
export * from './EditorMaskWrapper';
|
||||
export { whereIsMouse } from './EditorMask';
|
||||
|
@ -22,6 +22,6 @@ export const initEventBus = () => {
|
||||
off: emitter.off,
|
||||
send: emitter.emit,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type EventBusType = ReturnType<typeof initEventBus>
|
||||
export type EventBusType = ReturnType<typeof initEventBus>;
|
||||
|
Loading…
Reference in New Issue
Block a user