pixel whois, keybinds & fix coords not showing on hover (CanvasMeta) (related #11)

This commit is contained in:
Grant 2024-06-03 21:05:22 -06:00
parent 28cadf07ee
commit 6f7aad5da8
12 changed files with 609 additions and 5 deletions

View File

@ -11,6 +11,8 @@ import { ChatContext } from "../contexts/ChatContext";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { AuthErrors } from "./AuthErrors";
import "../lib/keybinds";
import { PixelWhoisSidebar } from "./PixelWhoisSidebar";
const Chat = lazy(() => import("./Chat/Chat"));
@ -34,6 +36,7 @@ const AppInner = () => {
<DebugModal />
<SettingsSidebar />
<PixelWhoisSidebar />
<AuthErrors />
<ToastContainer position="top-left" />

View File

@ -8,6 +8,7 @@ import throttle from "lodash.throttle";
import { IPosition } from "@sc07-canvas/lib/src/net";
import { Template } from "./Template";
import { IRouterData, Router } from "../lib/router";
import { KeybindManager } from "../lib/keybinds";
export const CanvasWrapper = () => {
const { config } = useAppContext();
@ -26,14 +27,64 @@ export const CanvasWrapper = () => {
const CanvasInner = () => {
const canvasRef = useRef<HTMLCanvasElement | null>();
const canvas = useRef<Canvas>();
const { config, setCanvasPosition, setCursorPosition } = useAppContext();
const { config, setCanvasPosition, setCursorPosition, setPixelWhois } =
useAppContext();
const PanZoom = useContext(RendererContext);
useEffect(() => {
if (!canvasRef.current) return;
canvas.current = new Canvas(canvasRef.current!, PanZoom);
const handlePixelWhois = ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => {
if (!canvas.current) {
console.warn(
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
);
return;
}
const [x, y] = canvas.current.screenToPos(clientX, clientY);
if (x < 0 || y < 0) return; // discard if out of bounds
// canvas size can dynamically change, so we need to check the current config
// we're depending on canvas.instance's config so we don't have to use a react dependency
if (canvas.current.hasConfig()) {
const {
canvas: {
size: [width, height],
},
} = canvas.current.getConfig();
if (x >= width || y >= height) return; // out of bounds
} else {
// although this should never happen, log it
console.warn(
"[CanvasWrapper#handlePixelWhois] canvas config is not available yet"
);
}
// .......
// .......
// .......
// ...x...
// .......
// .......
// .......
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
setPixelWhois({ x, y, surrounding });
};
KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
return () => {
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
canvas.current!.destroy();
};
}, [PanZoom, setCursorPosition]);
@ -139,6 +190,19 @@ const CanvasInner = () => {
);
}
if (canvas.current) {
const pos = canvas.current?.panZoomTransformToCanvas();
setCanvasPosition({
x: pos.canvasX,
y: pos.canvasY,
zoom: state.scale >> 0,
});
} else {
console.warn(
"[CanvasWrapper] handleViewportMove has no canvas instance"
);
}
Router.queueUpdate();
};

View File

@ -0,0 +1,188 @@
import { Button, Spinner } from "@nextui-org/react";
import { useAppContext } from "../contexts/AppContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react";
import { api } from "../lib/utils";
import { UserCard } from "./Profile/UserCard";
interface IPixel {
userId: string;
x: number;
y: number;
color: string;
createdAt: Date;
}
interface IUser {
sub: string;
display_name?: string;
picture_url?: string;
profile_url?: string;
isAdmin: boolean;
isModerator: boolean;
}
interface IInstance {
hostname: string;
name?: string;
logo_url?: string;
banner_url?: string;
}
export const PixelWhoisSidebar = () => {
const { pixelWhois, setPixelWhois } = useAppContext();
const [loading, setLoading] = useState(true);
const [whois, setWhois] = useState<{
pixel: IPixel;
otherPixels: number;
user: IUser | null;
instance: IInstance | null;
}>();
useEffect(() => {
if (!pixelWhois) return;
setLoading(true);
setWhois(undefined);
api<
{
pixel: IPixel;
otherPixels: number;
user: IUser | null;
instance: IInstance | null;
},
"no_pixel"
>(`/api/canvas/pixel/${pixelWhois.x}/${pixelWhois.y}`)
.then(({ status, data }) => {
if (status === 200) {
if (data.success) {
setWhois({
pixel: data.pixel,
otherPixels: data.otherPixels,
user: data.user,
instance: data.instance,
});
} else {
// error wahhhhhh
}
} else {
// error wahhhh
}
})
.finally(() => {
setLoading(false);
});
}, [pixelWhois]);
return (
<div
className="sidebar sidebar-right"
style={{ ...(pixelWhois ? {} : { display: "none" }) }}
>
{loading && (
<div className="absolute top-0 left-0 w-full h-full bg-black/25 flex justify-center items-center z-50">
<div className="flex flex-col bg-white p-5 rounded-lg gap-5">
<Spinner />
Loading
</div>
</div>
)}
<header>
<h1>Pixel Whois</h1>
<div className="flex-grow"></div>
<Button size="sm" isIconOnly onClick={() => setPixelWhois(undefined)}>
<FontAwesomeIcon icon={faXmark} />
</Button>
</header>
<div className="w-full h-52 bg-gray-200 flex justify-center items-center">
<div className="w-[128px] h-[128px] bg-white">
<SmallCanvas
surrounding={pixelWhois?.surrounding}
style={{ width: "100%" }}
/>
</div>
</div>
<section>{whois?.user && <UserCard user={whois.user} />}</section>
<section>
<table className="w-full">
<tr>
<th>Placed At</th>
<td>{whois?.pixel.createdAt?.toString()}</td>
</tr>
<tr>
<th>Covered Pixels</th>
<td>{whois?.otherPixels}</td>
</tr>
</table>
</section>
</div>
);
};
const SmallCanvas = ({
surrounding,
...props
}: {
surrounding: string[][] | undefined;
} & ComponentPropsWithoutRef<"canvas">) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!canvasRef.current) {
console.warn("[SmallCanvas] canvasRef unavailable");
return;
}
const ctx = canvasRef.current.getContext("2d");
if (!ctx) {
console.warn("[SmallCanvas] canvas context unavailable");
return;
}
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
if (surrounding) {
const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length;
const middle: [x: number, y: number] = [
Math.floor(surrounding[0].length / 2),
Math.floor(surrounding.length / 2),
];
for (let y = 0; y < surrounding.length; y++) {
for (let x = 0; x < surrounding[y].length; x++) {
let color = surrounding[y][x];
ctx.beginPath();
ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH);
ctx.fillStyle = color;
ctx.fill();
}
}
ctx.beginPath();
ctx.rect(
middle[0] * PIXEL_WIDTH,
middle[1] * PIXEL_WIDTH,
PIXEL_WIDTH,
PIXEL_WIDTH
);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 4;
ctx.stroke();
}
}, [surrounding]);
return (
<canvas
width={300}
height={300}
ref={(r) => (canvasRef.current = r)}
{...props}
/>
);
};

View File

@ -0,0 +1,100 @@
import { faMessage, faWarning, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Link, Spinner } from "@nextui-org/react";
import { MouseEvent, useEffect, useState } from "react";
import { toast } from "react-toastify";
interface IUser {
sub: string;
display_name?: string;
picture_url?: string;
profile_url?: string;
isAdmin: boolean;
isModerator: boolean;
}
const MATRIX_HOST = import.meta.env.VITE_MATRIX_HOST!; // eg aftermath.gg
const ELEMENT_HOST = import.meta.env.VITE_ELEMENT_HOST!; // eg https://chat.fediverse.events
const getMatrixLink = (user: IUser) => {
return `${ELEMENT_HOST}/#/user/@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`;
};
/**
* Small UserCard that shows profile picture, display name, full username, message box and (currently unused) view profile button
* @param param0
* @returns
*/
export const UserCard = ({ user }: { user: IUser }) => {
const [messageStatus, setMessageStatus] = useState<
"loading" | "no_account" | "has_account" | "error"
>("loading");
useEffect(() => {
setMessageStatus("loading");
fetch(
`https://${MATRIX_HOST}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`)}`
)
.then((req) => {
if (req.status === 200) {
setMessageStatus("has_account");
} else {
setMessageStatus("no_account");
}
})
.catch((e) => {
console.error(
"Error while getting Matrix account details for " + user.sub,
e
);
setMessageStatus("error");
toast.error(
"Error while getting Matrix account details for " + user.sub
);
});
}, [user]);
const handleMatrixClick = (e: MouseEvent) => {
if (messageStatus === "no_account") {
e.preventDefault();
toast.info("This user has not setup chat yet, you cannot message them");
}
};
return (
<div className="flex flex-col gap-1">
<div className="flex flex-row gap-2">
<img
src={user?.picture_url}
alt={`${user?.sub}'s profile`}
className="w-12 h-12"
/>
<div className="flex flex-col gap-0.25 grow">
<span>{user?.display_name}</span>
<span className="text-sm">{user?.sub}</span>
</div>
<div>
<Button
isIconOnly
as={Link}
href={getMatrixLink(user)}
target="_blank"
onClick={handleMatrixClick}
>
{messageStatus === "loading" ? (
<Spinner />
) : (
<FontAwesomeIcon
icon={messageStatus === "error" ? faWarning : faMessage}
color="inherit"
/>
)}
</Button>
</div>
</div>
<Button size="sm">View Profile</Button>
</div>
);
};

View File

@ -35,6 +35,11 @@ export const AppContext = ({ children }: PropsWithChildren) => {
// overlays visible
const [settingsSidebar, setSettingsSidebar] = useState(false);
const [pixelWhois, setPixelWhois] = useState<{
x: number;
y: number;
surrounding: string[][];
}>();
const [hasAdmin, setHasAdmin] = useState(false);
@ -130,6 +135,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
setLoadChat,
connected,
hasAdmin,
pixelWhois,
setPixelWhois,
}}
>
{!config && (

View File

@ -88,9 +88,51 @@ export class Canvas extends EventEmitter<CanvasEvents> {
return !!this.config;
}
getConfig() {
return this.config;
}
/**
* Get nearby pixels
* @param x
* @param y
* @param around (x,y) +- around
*/
getSurroundingPixels(x: number, y: number, around: number = 3) {
let pixels = [];
for (let offsetY = 0; offsetY <= around + 1; offsetY++) {
let arr = [];
for (let offsetX = 0; offsetX <= around + 1; offsetX++) {
let targetX = x + (offsetX - around + 1);
let targetY = y + (offsetY - around + 1);
let pixel = this.pixels[targetX + "_" + targetY];
if (pixel) {
arr.push("#" + (this.Pallete.getColor(pixel.color)?.hex || "ffffff"));
} else {
arr.push("transparent");
}
}
pixels.push(arr);
}
return pixels;
}
handleMouseDown(e: ClickEvent) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
if (!e.alt && !e.ctrl && !e.meta && !e.shift && e.button === "LCLICK") {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
} else {
// KeybindManager.handleInteraction({
// key: e.button,
// alt: e.alt,
// ctrl: e.ctrl,
// meta: e.meta,
// shift: e.meta
// }, )
}
}
handleMouseMove(e: HoverEvent) {

View File

@ -0,0 +1,126 @@
import EventEmitter from "eventemitter3";
import { EnforceObjectType } from "./utils";
interface IKeybind {
key: KeyboardEvent["code"] | "LCLICK" | "RCLICK" | "MCLICK";
alt?: boolean;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}
interface EmittedKeybind {
clientX: number;
clientY: number;
}
export const enforceObjectType: EnforceObjectType<IKeybind> = (v) => v;
const KEYBINDS = enforceObjectType({
PIXEL_WHOIS: {
key: "LCLICK",
shift: true,
},
});
class KeybindManager_ extends EventEmitter<{
[k in keyof typeof KEYBINDS]: (args: EmittedKeybind) => void;
}> {
constructor() {
super();
// setup listeners
document.addEventListener("keyup", this.handleKeyup);
document.addEventListener("click", this.handleClick);
}
destroy() {
// remove listeners
// this is global and doesn't depend on any elements, so this shouldn't need to be called
}
handleKeyup = (e: KeyboardEvent) => {
// discard if in an input element
const blacklistedElements = ["INPUT"];
if (e.target instanceof HTMLElement) {
if (blacklistedElements.indexOf(e.target.tagName) > -1) {
return;
}
}
let isHandled = this.handleInteraction(
{
key: e.code,
alt: e.altKey,
ctrl: e.ctrlKey,
meta: e.metaKey,
shift: e.shiftKey,
},
{
clientX: -1,
clientY: -1,
}
);
if (isHandled) e.preventDefault();
};
handleClick = (e: MouseEvent) => {
let button: "LCLICK" | "RCLICK" | "MCLICK" = ["LCLICK", "MCLICK", "RCLICK"][
e.button
] as any;
let isHandled = this.handleInteraction(
{
key: button,
alt: e.altKey,
ctrl: e.ctrlKey,
meta: e.metaKey,
shift: e.shiftKey,
},
{
clientX: e.clientX,
clientY: e.clientY,
}
);
if (isHandled) e.preventDefault();
};
/**
* Handle interaction
* @param key
* @returns if handled
*/
handleInteraction(key: IKeybind, emit: EmittedKeybind): boolean {
let isHandled = false;
for (const [name_, keybind] of Object.entries(KEYBINDS)) {
const name: keyof typeof KEYBINDS = name_ as any;
if (keybind.key !== key.key) continue;
if (typeof keybind.alt !== "undefined" && keybind.alt !== key.alt)
continue;
if (typeof keybind.ctrl !== "undefined" && keybind.ctrl !== key.ctrl)
continue;
if (typeof keybind.meta !== "undefined" && keybind.meta !== key.meta)
continue;
if (typeof keybind.shift !== "undefined" && keybind.shift !== key.shift)
continue;
this.emit(name, emit);
isHandled = true;
}
return isHandled;
}
getKeybind(key: keyof typeof KEYBINDS) {
return KEYBINDS[key];
}
}
export const KeybindManager = new KeybindManager_();

View File

@ -1,11 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = any>(
export const api = async <T = unknown, Error = string>(
endpoint: string,
method: "GET" | "POST" = "GET",
body?: unknown
): Promise<{
status: number;
data: ({ success: true } & T) | { success: false; error: string };
data: ({ success: true } & T) | { success: false; error: Error };
}> => {
const API_HOST = import.meta.env.VITE_API_HOST || "";
@ -32,3 +32,7 @@ export const api = async <T = any>(
data,
};
};
export type EnforceObjectType<T> = <V extends { [k: string]: T }>(
v: V
) => { [k in keyof V]: T };

View File

@ -44,6 +44,9 @@ export interface IAppContext {
setLoadChat: (v: boolean) => void;
connected: boolean;
pixelWhois?: { x: number; y: number; surrounding: string[][] };
setPixelWhois: (v: this["pixelWhois"]) => void;
hasAdmin: boolean;
}

View File

@ -89,8 +89,15 @@ interface ISetup {
// TODO: move these event interfaces out
export interface ClickEvent {
button: "LCLICK" | "MCLICK" | "RCLICK";
clientX: number;
clientY: number;
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
}
export interface HoverEvent {
@ -596,8 +603,15 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
// difference from the start position to the up position is very very slow,
// so it's most likely intended to be a click
this.emit("click", {
button: ["LCLICK", "MCLICK", "RCLICK"][e.button] as any,
clientX: e.clientX,
clientY: e.clientY,
alt: e.altKey,
ctrl: e.ctrlKey,
meta: e.metaKey,
shift: e.shiftKey,
});
}
}

View File

@ -190,4 +190,48 @@ app.get("/callback", async (req, res) => {
res.redirect("/");
});
// TODO: Ratelimiting #40
app.get("/canvas/pixel/:x/:y", async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
if (isNaN(x) || isNaN(y)) {
return res
.status(400)
.json({ success: false, error: "x or y is not a number" });
}
const pixel = await Canvas.getPixel(x, y);
if (!pixel) {
return res.json({ success: false, error: "no_pixel" });
}
const otherPixels = await prisma.pixel.count({ where: { x, y } });
const user = await prisma.user.findFirst({ where: { sub: pixel.userId } });
const instance = await prisma.instance.findFirst({
where: { hostname: pixel.userId.split("@")[1] },
});
res.json({
success: true,
pixel,
otherPixels: otherPixels - 1,
user: user && {
sub: user.sub,
display_name: user.display_name,
picture_url: user.picture_url,
profile_url: user.profile_url,
isAdmin: user.isAdmin,
isModerator: user.isModerator,
},
instance: instance && {
hostname: instance.hostname,
name: instance.name,
logo_url: instance.logo_url,
banner_url: instance.banner_url,
},
});
});
export default app;

View File

@ -151,6 +151,15 @@ class Canvas {
return await this.canvasToRedis();
}
async getPixel(x: number, y: number) {
return await prisma.pixel.findFirst({
where: {
x,
y,
},
});
}
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
const redis = await Redis.getClient();