diff --git a/packages/client/src/components/Moderation/ModModal.tsx b/packages/client/src/components/Moderation/ModModal.tsx index 61c5c6061f72a9e4cbf63db715cecdfc194dd611..b8af51400dca945965f835fb27f84f52ddba59ff 100644 --- a/packages/client/src/components/Moderation/ModModal.tsx +++ b/packages/client/src/components/Moderation/ModModal.tsx @@ -1,4 +1,5 @@ import { + Button, Modal, ModalBody, ModalContent, @@ -9,10 +10,17 @@ import { useAppContext } from "../../contexts/AppContext"; import { useCallback, useEffect, useState } from "react"; import { KeybindManager } from "../../lib/keybinds"; import { Canvas } from "../../lib/canvas"; +import { toast } from "react-toastify"; +import { api, handleError } from "../../lib/utils"; export const ModModal = () => { const { showModModal, setShowModModal, hasAdmin } = useAppContext(); const [bypassCooldown, setBypassCooldown_] = useState(false); + const [selectedCoords, setSelectedCoords] = useState<{ + start: [x: number, y: number]; + end: [x: number, y: number]; + }>(); + const [loading, setLoading] = useState(false); useEffect(() => { setBypassCooldown_(Canvas.instance?.getCooldownBypass() || false); @@ -33,6 +41,26 @@ export const ModModal = () => { }; }, [hasAdmin]); + useEffect(() => { + const previousClicks = Canvas.instance?.previousCanvasClicks; + + if (previousClicks && previousClicks.length === 2) { + let start: [number, number] = [previousClicks[0].x, previousClicks[0].y]; + let end: [number, number] = [previousClicks[1].x, previousClicks[1].y]; + + if (start[0] < end[0] && start[1] < end[1]) { + setSelectedCoords({ + start, + end, + }); + } else { + setSelectedCoords(undefined); + } + } else { + setSelectedCoords(undefined); + } + }, [showModModal]); + const setBypassCooldown = useCallback( (value: boolean) => { setBypassCooldown_(value); @@ -41,6 +69,39 @@ export const ModModal = () => { [setBypassCooldown_] ); + const doUndoArea = useCallback(() => { + if (!selectedCoords) return; + if ( + !confirm( + `Are you sure you want to undo (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})\n\nThis will affect ~${(selectedCoords.end[0] - selectedCoords.start[0]) * (selectedCoords.end[1] - selectedCoords.start[1])} pixels!` + ) + ) { + return; + } + + setLoading(true); + api("/api/admin/canvas/undo", "PUT", { + start: { x: selectedCoords.start[0], y: selectedCoords.start[1] }, + end: { x: selectedCoords.end[0], y: selectedCoords.end[1] }, + }) + .then(({ status, data }) => { + if (status === 200) { + if (data.success) { + toast.success( + `Successfully undid area (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})` + ); + } else { + handleError({ status, data }); + } + } else { + handleError({ status, data }); + } + }) + .finally(() => { + setLoading(false); + }); + }, [selectedCoords]); + return ( @@ -54,6 +115,18 @@ export const ModModal = () => { > Bypass placement cooldown + {selectedCoords && ( + + )} + {!selectedCoords && ( + <> + right click two positions to get more options (first click + needs to be the top left most position) + + )} )} diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index 7c32f5b292387b913b0353fa9e89e84d3086192f..dd8f3ba2464c0dc8e6e98d272883bdf73563e282 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -194,6 +194,8 @@ export class Canvas extends EventEmitter { ); }; + previousCanvasClicks: { x: number; y: number }[] = []; + handleMouseDown(e: ClickEvent) { if (!e.alt && !e.ctrl && !e.meta && !e.shift && e.button === "LCLICK") { const [x, y] = this.screenToPos(e.clientX, e.clientY); @@ -207,6 +209,16 @@ export class Canvas extends EventEmitter { // shift: e.meta // }, ) } + + if (e.button === "RCLICK" && !e.alt && !e.ctrl && !e.meta && !e.shift) { + const [x, y] = this.screenToPos(e.clientX, e.clientY); + + // keep track of the last X pixels right clicked + // used by the ModModal to determine areas selected + + this.previousCanvasClicks.push({ x, y }); + this.previousCanvasClicks = this.previousCanvasClicks.slice(-2); + } } handleMouseMove(e: HoverEvent) { diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts index 0c579a122c3413ca52f7d7ef9fee90be8700323c..6f7776d1f6718ea39a2f1a18eddfb295f37edaf7 100644 --- a/packages/client/src/lib/utils.ts +++ b/packages/client/src/lib/utils.ts @@ -34,7 +34,7 @@ export const rgbToHex = (r: number, g: number, b: number) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const api = async ( endpoint: string, - method: "GET" | "POST" = "GET", + method: "GET" | "POST" | "PUT" = "GET", body?: unknown ): Promise<{ status: number; diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts index 276c129d6a24729d4eba3cb3db88661592f7d2cd..ba86a69acb72abb588087df0d98bc65430d47751 100644 --- a/packages/lib/src/renderer/PanZoom.ts +++ b/packages/lib/src/renderer/PanZoom.ts @@ -529,6 +529,10 @@ export class PanZoom extends EventEmitter { registerMouseEvents() { console.debug("[PanZoom] Registering mouse events to $wrapper & document"); + this.$wrapper.addEventListener("contextmenu", (e) => { + e.preventDefault(); + }); + // zoom this.$wrapper.addEventListener("wheel", this._mouse_wheel, { passive: true, diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml index 2336382a274851e846191268161989c36d029012..e72eb3d3ca54ed1c575af1e4b53814a4dffe74aa 100644 --- a/packages/server/prisma/dbml/schema.dbml +++ b/packages/server/prisma/dbml/schema.dbml @@ -138,6 +138,7 @@ Enum AuditLogAction { CANVAS_FILL CANVAS_FREEZE CANVAS_UNFREEZE + CANVAS_AREA_UNDO } Ref: Pixel.userId > User.sub diff --git a/packages/server/prisma/migrations/20240711204100_add_canvas_area_undo_to_audit_log/migration.sql b/packages/server/prisma/migrations/20240711204100_add_canvas_area_undo_to_audit_log/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..6556cb6766838129696ce498b89c813d2c45736a --- /dev/null +++ b/packages/server/prisma/migrations/20240711204100_add_canvas_area_undo_to_audit_log/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_AREA_UNDO'; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index d85cce5c235164d12528007c759008759d365fe1..9886e2ad34b557c39cffd071b2efc7510f5f87a2 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -156,6 +156,7 @@ enum AuditLogAction { CANVAS_FILL CANVAS_FREEZE CANVAS_UNFREEZE + CANVAS_AREA_UNDO } model AuditLog { diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index cd2ddac13d53bae17105f97c461353ac1fe28d0a..b3a75f5a91443edac35f5dadaba4ac8aa8ed2080 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -175,23 +175,123 @@ app.post("/canvas/stress", async (req, res) => { return; } + const style: "random" | "xygradient" = req.body.style || "random"; + const width: number = req.body.width; const height: number = req.body.height; + const user = (await User.fromAuthSession(req.session.user!))!; + const paletteColors = await prisma.paletteColor.findMany({}); + + let promises: Promise[] = []; for (let x = 0; x < width; x++) { for (let y = 0; y < height; y++) { - let color = Math.floor(Math.random() * 30) + 1; - SocketServer.instance.io.emit("pixel", { - x, - y, - color, - }); + promises.push( + new Promise(async (res) => { + let colorIndex: number; + if (style === "xygradient") { + colorIndex = + Math.floor((x / width) * (paletteColors.length / 2)) + + Math.floor((y / height) * (paletteColors.length / 2)); + } else { + colorIndex = Math.floor(Math.random() * paletteColors.length); + } + + let color = paletteColors[colorIndex]; + + await Canvas.setPixel(user, x, y, color.hex, false); + + SocketServer.instance.io.emit("pixel", { + x, + y, + color: color.id, + }); + res(undefined); + }) + ); } } + await Promise.allSettled(promises); res.send("ok"); }); +/** + * Undo a square + * + * @header X-Audit + * @body start.x number + * @body start.y number + * @body end.x number + * @body end.y number + */ +app.put("/canvas/undo", async (req, res) => { + if ( + typeof req.body?.start?.x !== "number" || + typeof req.body?.start?.y !== "number" + ) { + res + .status(400) + .json({ success: false, error: "start position is invalid" }); + return; + } + + if ( + typeof req.body?.end?.x !== "number" || + typeof req.body?.end?.y !== "number" + ) { + res.status(400).json({ success: false, error: "end position is invalid" }); + return; + } + + const user_sub = + req.session.user!.user.username + + "@" + + req.session.user!.service.instance.hostname; + const start_position: [x: number, y: number] = [ + req.body.start.x, + req.body.start.y, + ]; + const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y]; + + const width = end_position[0] - start_position[0]; + const height = end_position[1] - start_position[1]; + + const pixels = await Canvas.undoArea(start_position, end_position); + const paletteColors = await prisma.paletteColor.findMany({}); + + for (const pixel of pixels) { + switch (pixel.status) { + case "fulfilled": { + const coveredPixel = pixel.value; + + SocketServer.instance.io.emit("pixel", { + x: pixel.pixel.x, + y: pixel.pixel.y, + color: coveredPixel + ? paletteColors.find((p) => p.hex === coveredPixel.color)?.id || -1 + : -1, + }); + break; + } + case "rejected": + console.log("Failed to undo pixel", pixel); + break; + } + } + + const user = (await User.fromAuthSession(req.session.user!))!; + const auditLog = await AuditLog.Factory(user.sub) + .doing("CANVAS_AREA_UNDO") + .reason(req.header("X-Audit") || null) + .withComment( + `Area undo (${start_position.join(",")}) -> (${end_position.join(",")})` + ) + .create(); + + res.json({ success: true, auditLog }); +}); + /** * Fill an area * diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index cd42ea1eb506c0059216592c1fbe3966af0cdfff..d89af57f1c90123669b58152b6e5ace49a095fa4 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -131,7 +131,7 @@ class Canvas { for (let y = 0; y < this.canvasSize[1]; y++) { const pixel = ( await prisma.pixel.findMany({ - where: { x, y }, + where: { x, y, deletedAt: null }, orderBy: { createdAt: "desc", }, @@ -163,8 +163,9 @@ class Canvas { * Undo a pixel * @throws Error "Pixel is not on top" * @param pixel + * @returns the pixel that now exists at that location */ - async undoPixel(pixel: Pixel) { + async undoPixel(pixel: Pixel): Promise { if (!pixel.isTop) throw new Error("Pixel is not on top"); await prisma.pixel.update({ @@ -175,9 +176,14 @@ class Canvas { }, }); - const coveringPixel = ( + const coveringPixel: Pixel | undefined = ( await prisma.pixel.findMany({ - where: { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt } }, + where: { + x: pixel.x, + y: pixel.y, + createdAt: { lt: pixel.createdAt }, + deletedAt: null, + }, orderBy: { createdAt: "desc" }, take: 1, }) @@ -191,6 +197,8 @@ class Canvas { }, }); } + + return coveringPixel; } /** @@ -290,6 +298,47 @@ class Canvas { }); } + /** + * Undo an area of pixels + * @param start + * @param end + * @returns + */ + async undoArea(start: [x: number, y: number], end: [x: number, y: number]) { + const now = Date.now(); + Logger.info("Starting undo area..."); + + const pixels = await prisma.pixel.findMany({ + where: { + x: { + gte: start[0], + lt: end[0], + }, + y: { + gte: start[1], + lt: end[1], + }, + isTop: true, + }, + }); + + const returns = await Promise.allSettled( + pixels.map((pixel) => this.undoPixel(pixel)) + ); + + Logger.info( + "Finished undo area in " + ((Date.now() - now) / 1000).toFixed(2) + "s" + ); + return returns.map((val, i) => { + const pixel = pixels[i]; + + return { + pixel: { x: pixel.x, y: pixel.y }, + ...val, + }; + }); + } + async fillArea( user: { sub: string }, start: [x: number, y: number],