follow scroll + zoom => follow scrollAndZoom
This commit is contained in:
parent
42a90def41
commit
f255a0835f
@ -225,22 +225,20 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFit = ({
|
export const zoomToFitBounds = ({
|
||||||
targetElements,
|
bounds,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport = false,
|
fitToViewport = false,
|
||||||
viewportZoomFactor = 0.7,
|
viewportZoomFactor = 0.7,
|
||||||
}: {
|
}: {
|
||||||
targetElements: readonly ExcalidrawElement[];
|
bounds: readonly [number, number, number, number];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
const [x1, y1, x2, y2] = bounds;
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
const centerY = (y1 + y2) / 2;
|
const centerY = (y1 + y2) / 2;
|
||||||
|
|
||||||
@ -267,7 +265,7 @@ export const zoomToFit = ({
|
|||||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||||
} else {
|
} else {
|
||||||
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
});
|
});
|
||||||
@ -296,6 +294,29 @@ export const zoomToFit = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const zoomToFit = ({
|
||||||
|
targetElements,
|
||||||
|
appState,
|
||||||
|
fitToViewport,
|
||||||
|
viewportZoomFactor,
|
||||||
|
}: {
|
||||||
|
targetElements: readonly ExcalidrawElement[];
|
||||||
|
appState: Readonly<AppState>;
|
||||||
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
|
fitToViewport: boolean;
|
||||||
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
|
viewportZoomFactor?: number;
|
||||||
|
}) => {
|
||||||
|
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||||
|
|
||||||
|
return zoomToFitBounds({
|
||||||
|
bounds: commonBounds,
|
||||||
|
appState,
|
||||||
|
fitToViewport,
|
||||||
|
viewportZoomFactor,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||||
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||||
// size, it won't be zoomed in.
|
// size, it won't be zoomed in.
|
||||||
|
@ -1500,18 +1500,21 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO follow-participant
|
|
||||||
// add zoom change
|
|
||||||
|
|
||||||
if (prevState.zoom.value !== this.state.zoom.value) {
|
if (prevState.zoom.value !== this.state.zoom.value) {
|
||||||
this.props?.onZoomChange?.(this.state.zoom);
|
this.props?.onScrollAndZoomChange?.({
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
scroll: { x: this.state.scrollX, y: this.state.scrollY },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
prevState.scrollX !== this.state.scrollX ||
|
prevState.scrollX !== this.state.scrollX ||
|
||||||
prevState.scrollY !== this.state.scrollY
|
prevState.scrollY !== this.state.scrollY
|
||||||
) {
|
) {
|
||||||
this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
|
this.props?.onScrollAndZoomChange?.({
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
scroll: { x: this.state.scrollX, y: this.state.scrollY },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
import { ExcalidrawImperativeAPI } from "../../types";
|
import { AppState, ExcalidrawImperativeAPI } from "../../types";
|
||||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
@ -16,6 +16,7 @@ import { Collaborator, Gesture } from "../../types";
|
|||||||
import {
|
import {
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
} from "../../utils";
|
} from "../../utils";
|
||||||
import {
|
import {
|
||||||
@ -71,6 +72,7 @@ import { resetBrowserStateVersions } from "../data/tabSync";
|
|||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
import { appJotaiStore } from "../app-jotai";
|
||||||
|
import { zoomToFitBounds } from "../../actions/actionCanvas";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const collabDialogShownAtom = atom(false);
|
export const collabDialogShownAtom = atom(false);
|
||||||
@ -89,8 +91,7 @@ export interface CollabAPI {
|
|||||||
/** function so that we can access the latest value from stale callbacks */
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
isCollaborating: () => boolean;
|
isCollaborating: () => boolean;
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
onScrollChange: CollabInstance["onScrollChange"];
|
onScrollAndZoomChange: CollabInstance["onScrollAndZoomChange"];
|
||||||
onZoomChange: CollabInstance["onZoomChange"];
|
|
||||||
startCollaboration: CollabInstance["startCollaboration"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
@ -164,8 +165,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
const collabAPI: CollabAPI = {
|
const collabAPI: CollabAPI = {
|
||||||
isCollaborating: this.isCollaborating,
|
isCollaborating: this.isCollaborating,
|
||||||
onPointerUpdate: this.onPointerUpdate,
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
onScrollChange: this.onScrollChange,
|
onScrollAndZoomChange: this.onScrollAndZoomChange,
|
||||||
onZoomChange: this.onZoomChange,
|
|
||||||
startCollaboration: this.startCollaboration,
|
startCollaboration: this.startCollaboration,
|
||||||
syncElements: this.syncElements,
|
syncElements: this.syncElements,
|
||||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
@ -510,8 +510,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||||
console.log("received update", decryptedData);
|
|
||||||
console.log(this.excalidrawAPI.getAppState());
|
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this.reconcileElements(decryptedData.payload.elements),
|
this.reconcileElements(decryptedData.payload.elements),
|
||||||
);
|
);
|
||||||
@ -520,8 +518,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
const { pointer, button, username, selectedElementIds } =
|
const { pointer, button, username, selectedElementIds } =
|
||||||
decryptedData.payload;
|
decryptedData.payload;
|
||||||
|
|
||||||
// console.log({ decryptedData });
|
|
||||||
|
|
||||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||||
decryptedData.payload.socketId ||
|
decryptedData.payload.socketId ||
|
||||||
// @ts-ignore legacy, see #2094 (#2097)
|
// @ts-ignore legacy, see #2094 (#2097)
|
||||||
@ -539,50 +535,22 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// TODO follow-participant
|
|
||||||
// case "SCROLL_LOCATION"
|
|
||||||
// case "ZOOM_VALUE"
|
|
||||||
// if following someone, update scroll and zoom
|
|
||||||
|
|
||||||
case "SCROLL_LOCATION": {
|
case "SCROLL_AND_ZOOM": {
|
||||||
const {
|
const { bounds } = decryptedData.payload;
|
||||||
scroll: { x, y },
|
|
||||||
} = decryptedData.payload;
|
|
||||||
|
|
||||||
const socketId: SocketUpdateDataSource["SCROLL_LOCATION"]["payload"]["socketId"] =
|
const socketId: SocketUpdateDataSource["SCROLL_AND_ZOOM"]["payload"]["socketId"] =
|
||||||
decryptedData.payload.socketId;
|
decryptedData.payload.socketId;
|
||||||
|
|
||||||
console.log({ decryptedData });
|
const _appState = this.excalidrawAPI.getAppState();
|
||||||
|
if (_appState.userToFollow === socketId) {
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const { appState } = zoomToFitBounds({
|
||||||
console.log({ appState });
|
appState: _appState,
|
||||||
|
bounds,
|
||||||
if (appState.userToFollow === socketId) {
|
fitToViewport: true,
|
||||||
this.excalidrawAPI.updateScene({
|
viewportZoomFactor: 1,
|
||||||
appState: {
|
|
||||||
scrollX: x,
|
|
||||||
scrollY: y,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ZOOM_VALUE": {
|
|
||||||
const { zoom } = decryptedData.payload;
|
|
||||||
|
|
||||||
console.log({ decryptedData });
|
|
||||||
|
|
||||||
const socketId: SocketUpdateDataSource["ZOOM_VALUE"]["payload"]["socketId"] =
|
|
||||||
decryptedData.payload.socketId;
|
|
||||||
|
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
|
||||||
|
|
||||||
if (appState.userToFollow === socketId) {
|
|
||||||
this.excalidrawAPI.updateScene({
|
|
||||||
appState: { zoom },
|
|
||||||
});
|
});
|
||||||
|
this.excalidrawAPI.updateScene({ appState });
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -814,7 +782,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||||
pointersMap: Gesture["pointers"];
|
pointersMap: Gesture["pointers"];
|
||||||
}) => {
|
}) => {
|
||||||
// console.log({ payload });
|
|
||||||
payload.pointersMap.size < 2 &&
|
payload.pointersMap.size < 2 &&
|
||||||
this.portal.socket &&
|
this.portal.socket &&
|
||||||
this.portal.broadcastMouseLocation(payload);
|
this.portal.broadcastMouseLocation(payload);
|
||||||
@ -822,26 +789,34 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
CURSOR_SYNC_TIMEOUT,
|
CURSOR_SYNC_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO follow-participant
|
onScrollAndZoomChange = throttle(
|
||||||
// - onScrollChange
|
(payload: { zoom: AppState["zoom"]; scroll: { x: number; y: number } }) => {
|
||||||
// -- broadCastScrollLocation
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
// - onZoomChange
|
|
||||||
// -- broadCastZoomValue
|
|
||||||
|
|
||||||
onZoomChange = throttle(
|
const { x: x1, y: y1 } = viewportCoordsToSceneCoords(
|
||||||
(payload: {
|
{ clientX: 0, clientY: 0 },
|
||||||
zoom: SocketUpdateDataSource["ZOOM_VALUE"]["payload"]["zoom"];
|
{
|
||||||
}) => {
|
offsetLeft: appState.offsetLeft,
|
||||||
this.portal.socket && this.portal.broadcastZoomValue(payload);
|
offsetTop: appState.offsetTop,
|
||||||
|
scrollX: payload.scroll.x,
|
||||||
|
scrollY: payload.scroll.y,
|
||||||
|
zoom: payload.zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onScrollChange = throttle(
|
const { x: x2, y: y2 } = viewportCoordsToSceneCoords(
|
||||||
(payload: {
|
{ clientX: appState.width, clientY: appState.height },
|
||||||
scrollX: SocketUpdateDataSource["SCROLL_LOCATION"]["payload"]["scroll"]["x"];
|
{
|
||||||
scrollY: SocketUpdateDataSource["SCROLL_LOCATION"]["payload"]["scroll"]["y"];
|
offsetLeft: appState.offsetLeft,
|
||||||
}) => {
|
offsetTop: appState.offsetTop,
|
||||||
this.portal.socket && this.portal.broadcastScrollLocation(payload);
|
scrollX: payload.scroll.x,
|
||||||
|
scrollY: payload.scroll.y,
|
||||||
|
zoom: payload.zoom,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.portal.socket &&
|
||||||
|
this.portal.broadcastScrollAndZoom({ bounds: [x1, y1, x2, y2] });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -214,8 +214,6 @@ class Portal {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("broadcastMouseLocation data", data);
|
|
||||||
|
|
||||||
return this._broadcastSocketData(
|
return this._broadcastSocketData(
|
||||||
data as SocketUpdateData,
|
data as SocketUpdateData,
|
||||||
true, // volatile
|
true, // volatile
|
||||||
@ -223,48 +221,19 @@ class Portal {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO follow-participant
|
broadcastScrollAndZoom = (payload: {
|
||||||
// - broadCastScrollLocation
|
bounds: [number, number, number, number];
|
||||||
// - broadCastZoomValue
|
|
||||||
|
|
||||||
broadcastScrollLocation = (payload: {
|
|
||||||
scrollX: SocketUpdateDataSource["SCROLL_LOCATION"]["payload"]["scroll"]["x"];
|
|
||||||
scrollY: SocketUpdateDataSource["SCROLL_LOCATION"]["payload"]["scroll"]["y"];
|
|
||||||
}) => {
|
}) => {
|
||||||
if (this.socket?.id) {
|
if (this.socket?.id) {
|
||||||
const data: SocketUpdateDataSource["SCROLL_LOCATION"] = {
|
const data: SocketUpdateDataSource["SCROLL_AND_ZOOM"] = {
|
||||||
type: "SCROLL_LOCATION",
|
type: "SCROLL_AND_ZOOM",
|
||||||
payload: {
|
payload: {
|
||||||
socketId: this.socket.id,
|
socketId: this.socket.id,
|
||||||
scroll: { x: payload.scrollX, y: payload.scrollY },
|
|
||||||
username: this.collab.state.username,
|
username: this.collab.state.username,
|
||||||
|
bounds: payload.bounds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("broadcastScrollLocation data", data);
|
|
||||||
|
|
||||||
return this._broadcastSocketData(
|
|
||||||
data as SocketUpdateData,
|
|
||||||
true, // volatile
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
broadcastZoomValue = (payload: {
|
|
||||||
zoom: SocketUpdateDataSource["ZOOM_VALUE"]["payload"]["zoom"];
|
|
||||||
}) => {
|
|
||||||
if (this.socket?.id) {
|
|
||||||
const data: SocketUpdateDataSource["ZOOM_VALUE"] = {
|
|
||||||
type: "ZOOM_VALUE",
|
|
||||||
payload: {
|
|
||||||
socketId: this.socket.id,
|
|
||||||
zoom: payload.zoom,
|
|
||||||
username: this.collab.state.username,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("broadcastZoomValue data", data);
|
|
||||||
|
|
||||||
return this._broadcastSocketData(
|
return this._broadcastSocketData(
|
||||||
data as SocketUpdateData,
|
data as SocketUpdateData,
|
||||||
true, // volatile
|
true, // volatile
|
||||||
|
@ -113,20 +113,12 @@ export type SocketUpdateDataSource = {
|
|||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SCROLL_LOCATION: {
|
SCROLL_AND_ZOOM: {
|
||||||
type: "SCROLL_LOCATION";
|
type: "SCROLL_AND_ZOOM";
|
||||||
payload: {
|
payload: {
|
||||||
socketId: string;
|
socketId: string;
|
||||||
scroll: { x: number; y: number };
|
|
||||||
username: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
ZOOM_VALUE: {
|
|
||||||
type: "ZOOM_VALUE";
|
|
||||||
payload: {
|
|
||||||
socketId: string;
|
|
||||||
zoom: AppState["zoom"];
|
|
||||||
username: string;
|
username: string;
|
||||||
|
bounds: [number, number, number, number];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
IDLE_STATUS: {
|
IDLE_STATUS: {
|
||||||
|
@ -649,16 +649,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
// TODO follow-participant
|
onScrollAndZoomChange={({ zoom, scroll }) => {
|
||||||
// add onZoomChange
|
collabAPI?.onScrollAndZoomChange({ zoom, scroll });
|
||||||
onZoomChange={(zoom) => {
|
|
||||||
console.log({ zoom });
|
|
||||||
collabAPI?.onZoomChange({ zoom });
|
|
||||||
}}
|
|
||||||
onScrollChange={(x, y) => {
|
|
||||||
// console.log({ x, y });
|
|
||||||
|
|
||||||
collabAPI?.onScrollChange({ scrollX: x, scrollY: y });
|
|
||||||
}}
|
}}
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
@ -41,7 +41,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onLinkOpen,
|
onLinkOpen,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onScrollChange,
|
onScrollChange,
|
||||||
onZoomChange,
|
onScrollAndZoomChange,
|
||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onLinkOpen={onLinkOpen}
|
onLinkOpen={onLinkOpen}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onScrollChange={onScrollChange}
|
onScrollChange={onScrollChange}
|
||||||
onZoomChange={onZoomChange}
|
onScrollAndZoomChange={onScrollAndZoomChange}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</App>
|
</App>
|
||||||
|
@ -362,6 +362,13 @@ export interface ExcalidrawProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onScrollChange?: (scrollX: number, scrollY: number) => void;
|
onScrollChange?: (scrollX: number, scrollY: number) => void;
|
||||||
onZoomChange?: (zoom: Zoom) => void;
|
onZoomChange?: (zoom: Zoom) => void;
|
||||||
|
onScrollAndZoomChange?: ({
|
||||||
|
zoom,
|
||||||
|
scroll,
|
||||||
|
}: {
|
||||||
|
zoom: Zoom;
|
||||||
|
scroll: { x: number; y: number };
|
||||||
|
}) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user