mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): keep sub-pixel position and angle while dragging and on drop (#4766)
* fix(studio): keep sub-pixel position and size while dragging and on drop * test(studio): expect three-decimal rotation in the manual edits rotation test * fix(studio): keep resized boxes on whole pixels so a centred element stays put
This commit is contained in:
@@ -697,7 +697,7 @@ describe("resolveDomEditRotationGesture", () => {
|
||||
).toEqual({ angle: 15 });
|
||||
});
|
||||
|
||||
it("allows small pointer movements when the rounded angle changes", () => {
|
||||
it("allows small pointer movements and keeps the exact angle", () => {
|
||||
const nextRotation = resolveDomEditRotationGesture({
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
@@ -709,7 +709,7 @@ describe("resolveDomEditRotationGesture", () => {
|
||||
snap: false,
|
||||
});
|
||||
|
||||
expect(nextRotation.angle).toBe(1.4);
|
||||
expect(nextRotation.angle).toBe(1.432);
|
||||
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
|
||||
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SnapContext } from "./snapTargetCollection";
|
||||
import type { SnapGuidesState } from "./SnapGuideOverlay";
|
||||
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
||||
import { logSelect } from "../../utils/selectDebug";
|
||||
import { roundTo3 } from "../../utils/rounding";
|
||||
|
||||
export type GestureKind = "drag" | "resize" | "rotate";
|
||||
|
||||
@@ -197,10 +198,6 @@ function normalizeAngleDelta(delta: number): number {
|
||||
return ((((delta + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
function roundAngle(angle: number): number {
|
||||
return Math.round(angle * 10) / 10;
|
||||
}
|
||||
|
||||
export function resolveDomEditRotationGesture(input: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
@@ -223,7 +220,7 @@ export function resolveDomEditRotationGesture(input: {
|
||||
return {
|
||||
angle: input.snap
|
||||
? Math.round(angle / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
|
||||
: roundAngle(angle),
|
||||
: roundTo3(angle),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
|
||||
import { roundTo3 } from "../../utils/rounding";
|
||||
|
||||
interface LayoutBox {
|
||||
left: number;
|
||||
@@ -30,8 +31,8 @@ export function applyElementPositionOffset(
|
||||
): PatchOperation[] | ElementOffsetRefusal {
|
||||
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
if (!cs) return "anchored";
|
||||
const dx = Math.round(gestureOffset.x - readDragStart(el, "x"));
|
||||
const dy = Math.round(gestureOffset.y - readDragStart(el, "y"));
|
||||
const dx = roundTo3(gestureOffset.x - readDragStart(el, "x"));
|
||||
const dy = roundTo3(gestureOffset.y - readDragStart(el, "y"));
|
||||
// A Layers-panel pick lifts a static element to relative for display only.
|
||||
const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
|
||||
const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
|
||||
@@ -41,8 +42,8 @@ export function applyElementPositionOffset(
|
||||
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
||||
const before = layoutBox(el);
|
||||
if (isStatic) el.style.position = "relative";
|
||||
el.style.left = `${Math.round(baseLeft) + dx}px`;
|
||||
el.style.top = `${Math.round(baseTop) + dy}px`;
|
||||
el.style.left = `${roundTo3(baseLeft + dx)}px`;
|
||||
el.style.top = `${roundTo3(baseTop + dy)}px`;
|
||||
const after = layoutBox(el);
|
||||
const shiftedExactly =
|
||||
Math.abs(after.left - before.left - dx) <= 1 &&
|
||||
|
||||
@@ -146,6 +146,20 @@ describe("studio manual edits", () => {
|
||||
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
||||
});
|
||||
|
||||
it("keeps sub-pixel offsets, and box sizes on whole px so GSAP reads a -50% centring exactly", () => {
|
||||
const document = createDocument(`<div id="card" style="width: 160px; height: 90px"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
mockBoundingRect(card, 160, 90);
|
||||
|
||||
applyStudioPathOffsetDraft(card, { x: 14.25, y: -8.5 });
|
||||
applyStudioBoxSizeDraft(card, { width: 240.4, height: 135.6 });
|
||||
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14.25px");
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8.5px");
|
||||
expect(card.style.getPropertyValue("width")).toBe("240px");
|
||||
expect(card.style.getPropertyValue("height")).toBe("136px");
|
||||
});
|
||||
|
||||
it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
|
||||
const document = createDocument(`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
@@ -188,24 +202,24 @@ describe("studio manual edits", () => {
|
||||
);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
applyStudioRotation(card, { angle: 24.24 });
|
||||
applyStudioRotation(card, { angle: 24.2468 });
|
||||
|
||||
expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
|
||||
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
|
||||
expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
|
||||
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
|
||||
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
||||
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
||||
|
||||
applyStudioRotationDraft(card, { angle: -12.26 });
|
||||
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
|
||||
applyStudioRotationDraft(card, { angle: -12.2604 });
|
||||
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
||||
|
||||
const snapshot = captureStudioRotation(card);
|
||||
applyStudioRotationDraft(card, { angle: 45 });
|
||||
restoreStudioRotation(card, snapshot);
|
||||
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
|
||||
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
||||
});
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
||||
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
||||
} from "./manualEditsTypes";
|
||||
import { roundRotationAngle } from "./manualEditsParsing";
|
||||
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
||||
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
||||
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
||||
|
||||
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
||||
let studioManualEditGestureId = 0;
|
||||
@@ -203,8 +203,8 @@ function writeStudioPathOffsetVars(
|
||||
): void {
|
||||
prepareStudioPathOffsetBase(element, options.updateBase ?? true);
|
||||
element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true");
|
||||
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${Math.round(offset.x)}px`);
|
||||
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${Math.round(offset.y)}px`);
|
||||
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${roundTo3(offset.x)}px`);
|
||||
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${roundTo3(offset.y)}px`);
|
||||
}
|
||||
|
||||
/* ── Path offset apply ────────────────────────────────────────────── */
|
||||
@@ -325,7 +325,7 @@ export function applyStudioPathOffsetDraft(
|
||||
// Non-GSAP elements: use CSS translate as before.
|
||||
element.style.setProperty(
|
||||
"translate",
|
||||
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
||||
composeTranslateValue(element, `${roundTo3(offset.x)}px`, `${roundTo3(offset.y)}px`),
|
||||
);
|
||||
stripGsapTranslateFromTransform(element);
|
||||
}
|
||||
@@ -342,7 +342,7 @@ function readParentFlexBasisPixels(
|
||||
if (display !== "flex" && display !== "inline-flex") return null;
|
||||
|
||||
const direction = readStyleOrComputed(parent, "flex-direction").trim();
|
||||
return Math.round(Math.max(1, direction.startsWith("column") ? size.height : size.width));
|
||||
return roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
|
||||
}
|
||||
|
||||
function restoreStaleStudioScaleResize(element: HTMLElement): void {
|
||||
@@ -417,8 +417,8 @@ function writeStudioBoxSizeVars(
|
||||
}
|
||||
|
||||
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
||||
element.style.setProperty(STUDIO_WIDTH_PROP, `${Math.round(Math.max(1, size.width))}px`);
|
||||
element.style.setProperty(STUDIO_HEIGHT_PROP, `${Math.round(Math.max(1, size.height))}px`);
|
||||
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
|
||||
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
|
||||
}
|
||||
|
||||
function applyStudioBoxSizeDimensions(
|
||||
@@ -428,8 +428,8 @@ function applyStudioBoxSizeDimensions(
|
||||
writeStudioBoxSizeVars(element, size);
|
||||
restoreStaleStudioScaleResize(element);
|
||||
|
||||
const width = Math.round(Math.max(1, size.width));
|
||||
const height = Math.round(Math.max(1, size.height));
|
||||
const width = roundToLayoutPx(Math.max(1, size.width));
|
||||
const height = roundToLayoutPx(Math.max(1, size.height));
|
||||
element.style.setProperty("box-sizing", "border-box");
|
||||
element.style.setProperty("width", `${width}px`);
|
||||
element.style.setProperty("height", `${height}px`);
|
||||
@@ -525,7 +525,7 @@ function writeStudioRotationVars(
|
||||
): void {
|
||||
prepareStudioRotationBase(element, options.updateBase ?? true);
|
||||
element.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
||||
element.style.setProperty(STUDIO_ROTATION_PROP, `${roundRotationAngle(rotation.angle)}deg`);
|
||||
element.style.setProperty(STUDIO_ROTATION_PROP, `${roundTo3(rotation.angle)}deg`);
|
||||
element.style.setProperty("transform-origin", STUDIO_ROTATION_TRANSFORM_ORIGIN);
|
||||
}
|
||||
|
||||
@@ -545,6 +545,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
|
||||
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
||||
element.style.setProperty(
|
||||
"rotate",
|
||||
composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`),
|
||||
composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ export function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function roundRotationAngle(angle: number): number {
|
||||
return Math.round(angle * 10) / 10;
|
||||
}
|
||||
|
||||
/* ── File path utilities ──────────────────────────────────────────── */
|
||||
function normalizeStudioFileChangePath(path: string): string {
|
||||
return path
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ManualOffsetDragMatrix,
|
||||
} from "./manualOffsetDrag";
|
||||
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
||||
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
||||
|
||||
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
|
||||
expect(actual.a).toBeCloseTo(expected.a, 6);
|
||||
@@ -449,6 +450,19 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sub-pixel drags", () => {
|
||||
it("draft and commit both land the exact fractional position", () => {
|
||||
const { element, sets, member } = makeGsapDot();
|
||||
const m = member();
|
||||
applyManualOffsetDragDraft(m, 10.25, -3.5);
|
||||
const draft = sets.at(-1)!;
|
||||
expect(draft).toEqual({ x: m.baseGsap.x + 10.25, y: m.baseGsap.y - 3.5 });
|
||||
const offset = applyManualOffsetDragCommit(m, 10.25, -3.5);
|
||||
const { newX, newY } = computeDraggedGsapPosition(element, offset, m.baseGsap);
|
||||
expect({ x: newX, y: newY }).toEqual(draft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a stylesheet translate after a committed GSAP drag", () => {
|
||||
function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
|
||||
const window = new Window();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
|
||||
/**
|
||||
* Drag → GSAP position math, shared by the commit path
|
||||
* (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
|
||||
@@ -39,8 +41,8 @@ export function computeDraggedGsapPosition(
|
||||
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
|
||||
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
|
||||
return {
|
||||
newX: Math.round(baseGsapX + adjX),
|
||||
newY: Math.round(baseGsapY + adjY),
|
||||
newX: roundTo3(baseGsapX + adjX),
|
||||
newY: roundTo3(baseGsapY + adjY),
|
||||
baseGsapX,
|
||||
baseGsapY,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../components/editor/manualEditsTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
||||
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
||||
@@ -289,8 +289,8 @@ export async function commitStaticGsapSize(
|
||||
existingSet: GsapAnimation | null,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
): Promise<void> {
|
||||
const width = Math.round(size.width);
|
||||
const height = Math.round(size.height);
|
||||
const width = roundToLayoutPx(size.width);
|
||||
const height = roundToLayoutPx(size.height);
|
||||
if (existingSet) {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
@@ -320,7 +320,7 @@ export async function commitStaticGsapSize(
|
||||
|
||||
/** Rounded `n` when it's a positive finite number, else `fallback`. */
|
||||
function positiveOr(n: number, fallback: number): number {
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
|
||||
return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,8 +366,8 @@ export async function commitKeyframedSizeFromResize(
|
||||
const td = resolveTweenDuration(animatedTween);
|
||||
if (!(td > 0)) return false;
|
||||
|
||||
const newW = Math.round(size.width);
|
||||
const newH = Math.round(size.height);
|
||||
const newW = roundToLayoutPx(size.width);
|
||||
const newH = roundToLayoutPx(size.height);
|
||||
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
|
||||
@@ -31,7 +31,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
||||
import { resolveGroupTween } from "./gsapRuntimeBridge";
|
||||
import { logResize } from "../utils/resizeDebug";
|
||||
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
|
||||
@@ -287,8 +287,8 @@ export async function tryGsapResizeIntercept(
|
||||
}
|
||||
} else {
|
||||
resizeProps = {
|
||||
width: Math.round(size.width),
|
||||
height: Math.round(size.height),
|
||||
width: roundToLayoutPx(size.width),
|
||||
height: roundToLayoutPx(size.height),
|
||||
};
|
||||
}
|
||||
// Finalize a scale-route commit: tear down the gesture's inline width/height
|
||||
@@ -358,8 +358,8 @@ export async function tryGsapResizeIntercept(
|
||||
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
||||
// rounded value from this delta).
|
||||
const corrected = {
|
||||
x: Math.round(base.x + residual.x),
|
||||
y: Math.round(base.y + residual.y),
|
||||
x: roundTo3(base.x + residual.x),
|
||||
y: roundTo3(base.y + residual.y),
|
||||
};
|
||||
logResize("scale-finalize", {
|
||||
dropPoint: scaleDraftDropPoint,
|
||||
|
||||
@@ -319,6 +319,17 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
||||
expect(commitMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits the exact angle the draft showed", async () => {
|
||||
const commitMutation = vi.fn();
|
||||
|
||||
await tryGsapRotationIntercept(selection, 75.25, [], null, commitMutation);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
expect.objectContaining({ type: "add", properties: { rotation: 75.25 } }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
|
||||
const helperSkew = {
|
||||
id: "#puck-b-to-rotation",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
import { isGestureTransactionCommit } from "./gestureTransaction";
|
||||
import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
||||
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
||||
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
||||
import {
|
||||
@@ -446,7 +447,7 @@ export async function tryGsapRotationIntercept(
|
||||
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
||||
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
||||
// gesture's live preview already gsap.set this value (single source of truth).
|
||||
const newRotation = Math.round(angle);
|
||||
const newRotation = roundTo3(angle);
|
||||
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
||||
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
||||
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
||||
|
||||
@@ -384,7 +384,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
||||
commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
|
||||
});
|
||||
|
||||
expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70, 55);
|
||||
expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70.2, 54.6);
|
||||
expect(mocks.setPosition.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.resize.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
|
||||
return Math.round(val * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
|
||||
export function roundToLayoutPx(val: number): number {
|
||||
return Math.round(val);
|
||||
}
|
||||
|
||||
/** Round to 2 decimal places (centisecond precision for timeline values). */
|
||||
export function roundToCenti(val: number): number {
|
||||
return Math.round(val * 100) / 100;
|
||||
|
||||
Reference in New Issue
Block a user