mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(editor): eyedropper fixes and improvements (#11859)
Co-authored-by: rafa <rafaondjango@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDarkModeFilter", () => {
|
||||
it("restores black and white", () => {
|
||||
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
|
||||
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it.each([
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[4],
|
||||
COLOR_PALETTE.green[4],
|
||||
COLOR_PALETTE.blue[4],
|
||||
])("preserves the rendered dark-mode color for %s", (color) => {
|
||||
const filtered = applyDarkModeFilter(color);
|
||||
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
|
||||
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
@@ -96,12 +99,17 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const inverted = cssInvert(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_INVERT_PERCENT,
|
||||
);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
@@ -113,6 +121,44 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const _reverseDarkModeInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
|
||||
const denominator = 1 - 2 * p;
|
||||
const restore = (color: number) =>
|
||||
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
|
||||
|
||||
return {
|
||||
r: restore(r),
|
||||
g: restore(g),
|
||||
b: restore(b),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeDarkModeFilter = (color: string): string => {
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
const rgb = tc.toRgb();
|
||||
|
||||
// 180deg hue rotation is its own inverse, so undo it before the inversion.
|
||||
const rotatedBack = cssHueRotate(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
const restored = _reverseDarkModeInvert(
|
||||
rotatedBack.r,
|
||||
rotatedBack.g,
|
||||
rotatedBack.b,
|
||||
);
|
||||
|
||||
return rgbToHex(restored.r, restored.g, restored.b, alpha);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -251,17 +297,6 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The color the bucket fill tool actually fills with: the shared
|
||||
* `currentItemBackgroundColor`, falling back to green when that is
|
||||
* transparent (the tool's picker doesn't offer transparent, but the shared
|
||||
* state can hold it from the generic shape picker).
|
||||
*/
|
||||
export const getBucketFillBackgroundColor = (backgroundColor: string) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: backgroundColor;
|
||||
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
getBucketFillBackgroundColor,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
ARROW_TYPE,
|
||||
@@ -523,7 +522,7 @@ export const actionChangeBucketFillBackgroundColor = register<
|
||||
excludedColors={[COLOR_PALETTE.transparent]}
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getBucketFillBackgroundColor(
|
||||
color={app.bucketFill.getBucketFillBackgroundColor(
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) =>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
getBucketFillBackgroundColor,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
getSizeFromPoints,
|
||||
isTransparent,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -15,7 +18,10 @@ import {
|
||||
ShapeCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionChangeBucketFillBackgroundColor } from "../actions";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
@@ -72,7 +78,7 @@ export class AppBucketFill {
|
||||
};
|
||||
|
||||
cycleBackgroundColor = () => {
|
||||
const currentColor = getBucketFillBackgroundColor(
|
||||
const currentColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor);
|
||||
@@ -124,7 +130,7 @@ export class AppBucketFill {
|
||||
* element (no-op when nothing would change). One undoable step.
|
||||
*/
|
||||
private restyle = (element: NonDeletedExcalidrawElement) => {
|
||||
const backgroundColor = getBucketFillBackgroundColor(
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
if (
|
||||
@@ -149,7 +155,7 @@ export class AppBucketFill {
|
||||
private fill = (scenePointer: ScenePoint) => {
|
||||
// shared with the generic shape background, but a transparent fill would
|
||||
// be invisible, so fall back to a real color (appState is not mutated)
|
||||
const backgroundColor = getBucketFillBackgroundColor(
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
|
||||
@@ -277,4 +283,19 @@ export class AppBucketFill {
|
||||
// (even when the tool isn't locked).
|
||||
this.app.scheduleCapture();
|
||||
};
|
||||
|
||||
/**
|
||||
* The color the bucket fill tool actually fills with: the shared
|
||||
* `currentItemBackgroundColor`, falling back to green when that is
|
||||
* transparent (the tool's picker doesn't offer transparent, but the shared
|
||||
* state can hold it from the generic shape picker).
|
||||
*/
|
||||
public getBucketFillBackgroundColor = (
|
||||
backgroundColor: string,
|
||||
/** supply only for display purposes such as when applying to the cursor */
|
||||
theme?: Theme,
|
||||
) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
getBucketFillBackgroundColor,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive, isEraserActive } from "../appState";
|
||||
|
||||
@@ -105,16 +100,20 @@ export class AppCursor {
|
||||
return;
|
||||
}
|
||||
|
||||
const theme =
|
||||
activeTool.type === "eraser" ||
|
||||
activeTool.type === "laser" ||
|
||||
activeTool.type === "bucketfill"
|
||||
? this.app.state.theme
|
||||
: undefined;
|
||||
|
||||
const bucketFillColor =
|
||||
activeTool.type === "bucketfill"
|
||||
? getBucketFillBackgroundColor(
|
||||
? this.app.bucketFill.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
theme,
|
||||
)
|
||||
: undefined;
|
||||
const theme =
|
||||
activeTool.type === "eraser" || activeTool.type === "laser"
|
||||
? this.app.state.theme
|
||||
: undefined;
|
||||
const memo = this.toolCursorMemo;
|
||||
if (
|
||||
memo?.canvas === canvas &&
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
|
||||
.excalidraw-eye-dropper-preview {
|
||||
pointer-events: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
position: absolute;
|
||||
z-index: var(--zIndex-eyeDropperPreview);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
filter: var(--theme-filter);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--eye-dropper-preview-border-color, #222);
|
||||
box-shadow: 2px 2px 0 0 #4949497c;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-trigger {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { EVENT, KEYS, rgbToHex } from "@excalidraw/common";
|
||||
import {
|
||||
EVENT,
|
||||
isColorDark,
|
||||
KEYS,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@@ -13,12 +21,20 @@ import { useStable } from "../hooks/useStable";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
import { eyeDropperIconSvgPaths } from "./icons";
|
||||
import { positionElementBesideCursor } from "./positionElementBesideCursor";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
|
||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
|
||||
const eyeDropperCursorPaths = eyeDropperIconSvgPaths
|
||||
.map((path, idx) => `<path fill="${idx === 0 ? `#fff` : ``}" d="${path}" />`)
|
||||
.join("");
|
||||
const eyeDropperCursor = `url(data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="5">${eyeDropperCursorPaths}</g><g stroke="#1b1b1f" stroke-width="1.25">${eyeDropperCursorPaths}</g></svg>`,
|
||||
)}) 2 21, auto`;
|
||||
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
swapPreviewOnAlt?: boolean;
|
||||
@@ -66,6 +82,16 @@ export const EyeDropper: React.FC<{
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!eyeDropperContainer) {
|
||||
return;
|
||||
}
|
||||
eyeDropperContainer.style.cursor = eyeDropperCursor;
|
||||
return () => {
|
||||
eyeDropperContainer.style.cursor = "";
|
||||
};
|
||||
}, [eyeDropperContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
const colorPreviewDiv = ref.current;
|
||||
|
||||
@@ -94,6 +120,9 @@ export const EyeDropper: React.FC<{
|
||||
return rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||
};
|
||||
|
||||
const getColorToApply = (color: string) =>
|
||||
appState.theme === THEME.DARK ? removeDarkModeFilter(color) : color;
|
||||
|
||||
const mouseMoveListener = ({
|
||||
clientX,
|
||||
clientY,
|
||||
@@ -110,7 +139,7 @@ export const EyeDropper: React.FC<{
|
||||
height: colorPreviewDiv.offsetHeight,
|
||||
},
|
||||
container: eyeDropperContainer.getBoundingClientRect(),
|
||||
gap: 20,
|
||||
gap: 7,
|
||||
});
|
||||
|
||||
colorPreviewDiv.style.top = `${top}px`;
|
||||
@@ -121,13 +150,17 @@ export const EyeDropper: React.FC<{
|
||||
if (isHoldingPointerDown) {
|
||||
stableProps.onChange(
|
||||
colorPickerType,
|
||||
currentColor,
|
||||
getColorToApply(currentColor),
|
||||
stableProps.selectedElements,
|
||||
{ altKey },
|
||||
);
|
||||
}
|
||||
|
||||
colorPreviewDiv.style.background = currentColor;
|
||||
colorPreviewDiv.style.setProperty(
|
||||
"--eye-dropper-preview-border-color",
|
||||
isColorDark(currentColor) ? "#fff" : "#222",
|
||||
);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -158,7 +191,7 @@ export const EyeDropper: React.FC<{
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onSelect(getCurrentColor(event), event);
|
||||
onSelect(getColorToApply(getCurrentColor(event)), event);
|
||||
};
|
||||
|
||||
const keyDownListener = (event: KeyboardEvent) => {
|
||||
@@ -215,6 +248,7 @@ export const EyeDropper: React.FC<{
|
||||
excalidrawContainer,
|
||||
appState.offsetLeft,
|
||||
appState.offsetTop,
|
||||
appState.theme,
|
||||
]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1961,11 +1961,17 @@ export const alertTriangleIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const eyeDropperIconSvgPaths = [
|
||||
"M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z",
|
||||
"M11 7l6 6",
|
||||
] as const;
|
||||
|
||||
export const eyeDropperIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M11 7l6 6"></path>
|
||||
<path d="M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z"></path>
|
||||
{eyeDropperIconSvgPaths.map((path) => (
|
||||
<path key={path} d={path} />
|
||||
))}
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
getBucketFillBackgroundColor,
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
@@ -91,7 +90,7 @@ describe("bucket fill tool", () => {
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
const fallbackColor = getBucketFillBackgroundColor(
|
||||
const fallbackColor = h.app.bucketFill.getBucketFillBackgroundColor(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
const fallbackCursor = GlobalTestState.interactiveCanvas.style.cursor;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
import { KEYS, THEME } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { fireEvent, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("eye dropper", () => {
|
||||
it("keeps the color preview within the editor container", async () => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
@@ -24,6 +28,10 @@ describe("eye dropper", () => {
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-backdrop",
|
||||
)!;
|
||||
expect(eyeDropperContainer.style.cursor).toMatch(
|
||||
/^url\(data:image\/svg\+xml/,
|
||||
);
|
||||
expect(eyeDropperContainer.style.cursor).toMatch(/\) 2 21, auto$/);
|
||||
|
||||
eyeDropperContainer.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
@@ -44,7 +52,73 @@ describe("eye dropper", () => {
|
||||
// This position fits within the viewport, but not within the editor.
|
||||
fireEvent.pointerMove(window, { clientX: 480, clientY: 330 });
|
||||
|
||||
expect(preview.style.left).toBe("312px");
|
||||
expect(preview.style.top).toBe("212px");
|
||||
expect(preview.style.left).toBe("325px");
|
||||
expect(preview.style.top).toBe("225px");
|
||||
});
|
||||
|
||||
it("applies the unfiltered color in dark mode", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
theme={THEME.DARK}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ctx = h.app.canvas.getContext("2d")!;
|
||||
vi.spyOn(ctx, "getImageData").mockReturnValue({
|
||||
data: new Uint8ClampedArray([18, 18, 18, 255]),
|
||||
} as ImageData);
|
||||
|
||||
Keyboard.keyPress(KEYS.I);
|
||||
|
||||
const eyeDropperContainer = await waitFor(() => {
|
||||
const element =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-backdrop",
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
fireEvent.pointerUp(eyeDropperContainer, {
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
});
|
||||
|
||||
expect(h.state.currentItemBackgroundColor).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("contrasts the preview border with the sampled color", async () => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
|
||||
const ctx = h.app.canvas.getContext("2d")!;
|
||||
const getImageDataSpy = vi.spyOn(ctx, "getImageData").mockReturnValue({
|
||||
data: new Uint8ClampedArray([18, 18, 18, 255]),
|
||||
} as ImageData);
|
||||
|
||||
Keyboard.keyPress(KEYS.I);
|
||||
|
||||
const preview = await waitFor(() => {
|
||||
const element =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-preview",
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
expect(
|
||||
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
|
||||
).toBe("#fff");
|
||||
|
||||
getImageDataSpy.mockReturnValue({
|
||||
data: new Uint8ClampedArray([237, 237, 237, 255]),
|
||||
} as ImageData);
|
||||
fireEvent.pointerMove(window, { clientX: 50, clientY: 50 });
|
||||
|
||||
expect(
|
||||
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
|
||||
).toBe("#222");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1119,6 +1119,7 @@ export type AppClassProperties = {
|
||||
flowchart: App["flowchart"];
|
||||
drawShape: App["drawShape"];
|
||||
cursor: App["cursor"];
|
||||
bucketFill: App["bucketFill"];
|
||||
isToolLocked: App["isToolLocked"];
|
||||
getEffectiveGridSize: App["getEffectiveGridSize"];
|
||||
setPlugins: App["setPlugins"];
|
||||
|
||||
Reference in New Issue
Block a user