Skip to content
/**
* Handle alerts sent by the server (moderation or internal)
*/
import { IAlert, IAlertKeyedMessages } from "@sc07-canvas/lib/src/net";
import EventEmitter from "eventemitter3";
import { JSX } from "react";
import { toast } from "react-toastify";
/**
* Handles IAlert outside of react
* @param alert
*/
export const handleAlert = (alert: IAlert) => {
switch (alert.is) {
case "toast":
handleToast(alert);
break;
case "modal":
handleModal(alert);
break;
}
};
export const handleDismiss = (id: string) => {
toast.dismiss(id);
};
export interface IDynamicModal {
title: string | JSX.Element;
body: string | JSX.Element;
}
/**
* Dynamic modal event root
*
* These are consumed by src/DynamicModals.tsx
*/
interface IDynamicModalEvents {
showModal: (modal: IDynamicModal) => void;
}
class DynamicModalClass extends EventEmitter<IDynamicModalEvents> {}
export const DynamicModal = new DynamicModalClass();
const getMessage = <T extends keyof IAlertKeyedMessages>(
key: T,
metadata: IAlertKeyedMessages[T]
): { title: string | JSX.Element; body: string | JSX.Element } => {
switch (key) {
case "banned": {
let metadata_ = metadata as IAlertKeyedMessages["banned"];
const until = new Date(metadata_.until);
return {
title: "You have been banned.",
body:
"You will be unbanned in " +
((until.getTime() - Date.now()) / 1000).toFixed(0) +
" seconds",
};
}
case "unbanned": {
return {
title: "You have been unbanned.",
body: "",
};
}
default:
return {
title: "Unknown Message?",
body: "Unknown message: " + key,
};
}
};
const handleToast = (alert: IAlert<"toast">) => {
let Body: JSX.Element;
if ("title" in alert) {
Body = (
<div>
<b>{alert.title}</b>
{alert.body && <> {alert.body}</>}
</div>
);
} else {
const message = getMessage(alert.message_key, alert.metadata);
Body = (
<div>
<b>{message.title}</b>
{message.body}
</div>
);
}
toast(Body, {
toastId: alert.id,
type: alert.severity,
autoClose: alert.autoDismiss ? 5000 : false,
});
};
const handleModal = (alert: IAlert<"modal">) => {
let modal: IDynamicModal;
if ("title" in alert) {
modal = {
title: alert.title,
body: alert.body || "",
};
} else {
const message = getMessage(alert.message_key, alert.metadata);
modal = {
title: message.title,
body: message.body,
};
}
DynamicModal.emit("showModal", modal);
};
import EventEmitter from "eventemitter3";
import {
ClientConfig,
IPalleteContext,
IPosition,
Pixel,
} from "@sc07-canvas/lib/src/net";
import { ClientConfig, IPosition, Pixel } from "@sc07-canvas/lib/src/net";
import Network from "./network";
import {
ClickEvent,
HoverEvent,
PanZoom,
} from "@sc07-canvas/lib/src/renderer/PanZoom";
import { toast } from "react-toastify";
import { KeybindManager } from "./keybinds";
import { getRenderer } from "./utils";
import { CanvasPixel } from "./canvasRenderer";
import { CanvasUtils } from "./canvas.utils";
interface CanvasEvents {
/**
......@@ -20,63 +20,220 @@ interface CanvasEvents {
* @returns
*/
cursorPos: (position: IPosition) => void;
canvasReady: () => void;
}
export class Canvas extends EventEmitter<CanvasEvents> {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig;
private config: ClientConfig = {} as any;
private canvas: HTMLCanvasElement;
private PanZoom: PanZoom;
private ctx: CanvasRenderingContext2D;
private cursor = { x: -1, y: -1 };
private cursor: { x: number; y: number; color?: number } = { x: -1, y: -1 };
private pixels: {
[x_y: string]: { color: number; type: "full" | "pending" };
} = {};
lastPlace: number | undefined;
constructor(
config: ClientConfig,
canvas: HTMLCanvasElement,
PanZoom: PanZoom
) {
private bypassCooldown = false;
// private _delayedLoad: ReturnType<typeof setTimeout>;
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
super();
Canvas.instance = this;
getRenderer().startRender();
getRenderer().on("ready", () => this.emit("canvasReady"));
this.config = config;
this.canvas = canvas;
this.PanZoom = PanZoom;
this.ctx = canvas.getContext("2d")!;
canvas.width = config.canvas.size[0];
canvas.height = config.canvas.size[1];
this.loadRenderer();
this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
this.PanZoom.addListener("longPress", this.handleLongPress);
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
Network.waitFor("pixelLastPlaced").then(
([time]) => (this.lastPlace = time)
);
Network.on("pixel", this.handlePixel);
this.draw();
Network.on("square", this.handleSquare);
Network.on("pixelLastPlaced", this.handlePixelLastPlaced);
}
destroy() {
this._destroy = true;
getRenderer().stopRender();
getRenderer().off("ready");
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
this.PanZoom.removeListener("longPress", this.handleLongPress);
Network.off("pixel", this.handlePixel);
Network.off("square", this.handleSquare);
Network.off("pixelLastPlaced", this.handlePixelLastPlaced);
}
/**
* React.Strict remounts the main component, causing a quick remount, which then causes errors related to webworkers
*
* If #useCanvas fails, it's most likely due to that
*/
loadRenderer() {
try {
getRenderer().useCanvas(this.canvas, "main");
} catch (e) {
console.warn(
"[Canvas#loadRenderer] Failed at #useCanvas, this shouldn't be fatal",
e
);
}
}
setSize(width: number, height: number) {
getRenderer().setSize(width, height);
}
loadConfig(config: ClientConfig) {
this.config = config;
this.setSize(config.canvas.size[0], config.canvas.size[1]);
// we want the new one if possible
// (this might cause a timing issue though)
// if we don't clear the old one, if the canvas gets resized we get weird stretching
if (Object.keys(this.pixels).length > 0)
Network.clearPreviousState("canvas");
// Network.waitForState("canvas").then(([pixels]) => {
// console.log("loadConfig just received new canvas data");
// this.handleBatch(pixels);
// });
Network.on("canvas", (start, end, pixels) => {
console.log("[Canvas] received canvas section");
this.handleBatch(start, end, pixels);
});
const chunks = Network.getCanvasChunks();
console.log(`[Canvas] Received ${chunks.length} chunks to load`);
let loaded = 0;
for (const chunk of chunks) {
console.log(`[Canvas] Loading canvas chunk ${loaded}...`);
this.handleBatch(chunk.start, chunk.end, chunk.pixels);
loaded++;
}
}
hasConfig() {
return !!this.config;
}
getConfig() {
return this.config;
}
getPanZoom() {
return this.PanZoom;
}
setCooldownBypass(value: boolean) {
this.bypassCooldown = value;
}
getCooldownBypass() {
return this.bypassCooldown;
}
getAllPixels() {
let pixels: {
x: number;
y: number;
color: number;
}[] = [];
for (const [x_y, value] of Object.entries(this.pixels)) {
if (value.type === "pending") continue;
const [x, y] = x_y.split("_").map((v) => parseInt(v));
pixels.push({
x,
y,
color: value.color,
});
}
return pixels;
}
/**
* 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;
}
Network.off("canvas", this.handleBatch.bind(this));
getPixel(x: number, y: number): { color: number } | undefined {
return this.pixels[x + "_" + y];
}
handleLongPress = (clientX: number, clientY: number) => {
KeybindManager.handleInteraction(
{
key: "LONG_PRESS",
},
{
clientX,
clientY,
}
);
};
previousCanvasClicks: { x: number; y: number }[] = [];
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
// }, )
}
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) {
......@@ -98,36 +255,97 @@ export class Canvas extends EventEmitter<CanvasEvents> {
this.emit("cursorPos", this.cursor);
}
handleBatch(pixels: string[]) {
pixels.forEach((hex, index) => {
const x = index % this.config.canvas.size[0];
const y = index / this.config.canvas.size[1];
const color = this.Pallete.getColorFromHex(hex);
handleSquare = (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => {
const palette = this.Pallete.getColor(color);
let serializeBuild: CanvasPixel[] = [];
for (let x = start[0]; x <= end[0]; x++) {
for (let y = start[1]; y <= end[1]; y++) {
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[x + "_" + y] = {
type: "full",
color: palette?.id || -1,
};
serializeBuild.push({
x,
y,
hex:
!palette || palette?.hex === "transparent" ? "null" : palette.hex,
});
}
}
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
});
}
getRenderer().usePixels(serializeBuild);
};
handleBatch = (
start: [x: number, y: number],
end: [x: number, y: number],
pixels: string[]
) => {
if (!this.config.canvas) {
throw new Error("handleBatch called with no config");
}
let serializeBuild: CanvasPixel[] = [];
const width = end[0] - start[0];
const height = end[1] - start[1];
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const hex = pixels[width * y + x];
const palette = this.Pallete.getColorFromHex(hex);
const canvasX = x + start[0];
const canvasY = y + start[1];
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[canvasX + "_" + canvasY] = {
type: "full",
color: palette?.id || -1,
};
serializeBuild.push({
x: canvasX,
y: canvasY,
hex: hex === "transparent" ? "null" : hex,
});
}
}
getRenderer().usePixels(serializeBuild, true);
};
handlePixel = ({ x, y, color }: Pixel) => {
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[x + "_" + y] = {
color,
type: "full",
color,
};
const palette = this.Pallete.getColor(color);
getRenderer().usePixel({ x, y, hex: palette?.hex || "null" });
};
handlePixelLastPlaced = (time: number) => {
this.lastPlace = time;
};
palleteCtx: IPalleteContext = {};
Pallete = {
getColor: (colorId: number) => {
return this.config.pallete.colors.find((c) => c.id === colorId);
},
getSelectedColor: () => {
if (!this.palleteCtx.color) return undefined;
if (!this.cursor.color) return undefined;
return this.Pallete.getColor(this.palleteCtx.color);
return this.Pallete.getColor(this.cursor.color);
},
getColorFromHex: (hex: string) => {
......@@ -135,8 +353,14 @@ export class Canvas extends EventEmitter<CanvasEvents> {
},
};
updatePallete(pallete: IPalleteContext) {
this.palleteCtx = pallete;
/**
* Changes the cursor color as tracked by the Canvas instance
*
* @see Toolbar/Palette.tsx
* @param color
*/
updateCursor(color?: number) {
this.cursor.color = color;
}
place(x: number, y: number) {
......@@ -151,36 +375,61 @@ export class Canvas extends EventEmitter<CanvasEvents> {
// }
Network.socket
.emitWithAck("place", {
x,
y,
color: this.Pallete.getSelectedColor()!.id,
})
.emitWithAck(
"place",
{
x,
y,
color: this.Pallete.getSelectedColor()!.id,
},
this.bypassCooldown
)
.then((ack) => {
if (ack.success) {
this.lastPlace = Date.now();
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("error: " + ack.error);
console.warn(
"Attempted to place pixel",
{ x, y, color: this.Pallete.getSelectedColor()!.id },
"and got error",
ack
);
switch (ack.error) {
case "invalid_pixel":
toast.error(
"Cannot place, invalid pixel location. Are you even on the canvas?"
);
break;
case "no_user":
toast.error("You are not logged in.");
break;
case "pixel_already_pending":
toast.error("You are already placing a pixel");
break;
case "palette_color_invalid":
toast.error("This isn't a color that you can use...?");
break;
case "pixel_cooldown":
toast.error("You're on pixel cooldown, cannot place");
break;
case "you_already_placed_that":
toast.error("You already placed this color at this location");
break;
default:
toast.error("Error while placing pixel: " + ack.error);
}
}
});
}
canvasToPanZoomTransform(x: number, y: number) {
let transformX = 0;
let transformY = 0;
if (this.PanZoom.flags.useZoom) {
// CSS Zoom does not alter this (obviously)
transformX = this.canvas.width / 2 - x;
transformY = this.canvas.height / 2 - y;
} else {
transformX = this.canvas.width / 2 - x;
transformY = this.canvas.height / 2 - y;
}
return { transformX, transformY };
return CanvasUtils.canvasToPanZoomTransform(
x,
y,
[this.canvas.width, this.canvas.height],
this.PanZoom.flags.useZoom
);
}
panZoomTransformToCanvas() {
......@@ -223,6 +472,12 @@ export class Canvas extends EventEmitter<CanvasEvents> {
document.body.appendChild(el);
}
/**
* Screen (clientX, clientY) to Canvas position
* @param x
* @param y
* @returns
*/
screenToPos(x: number, y: number) {
// the rendered dimentions in the browser
const rect = this.canvas.getBoundingClientRect();
......@@ -254,44 +509,4 @@ export class Canvas extends EventEmitter<CanvasEvents> {
return [output.x, output.y];
}
draw() {
this.ctx.imageSmoothingEnabled = false;
const bezier = (n: number) => n * n * (3 - 2 * n);
this.ctx.globalAlpha = 1;
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(
0,
0,
this.config.canvas.size[0],
this.config.canvas.size[1]
);
for (const [x_y, pixel] of Object.entries(this.pixels)) {
const [x, y] = x_y.split("_").map((a) => parseInt(a));
this.ctx.globalAlpha = pixel.type === "full" ? 1 : 0.5;
this.ctx.fillStyle =
pixel.color > -1
? "#" + this.Pallete.getColor(pixel.color)!.hex
: "transparent";
this.ctx.fillRect(x, y, 1, 1);
}
if (this.palleteCtx.color && this.cursor.x > -1 && this.cursor.y > -1) {
const color = this.config.pallete.colors.find(
(c) => c.id === this.palleteCtx.color
);
let t = ((Date.now() / 100) % 10) / 10;
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
this.ctx.fillStyle = "#" + color!.hex;
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
}
if (!this._destroy) window.requestAnimationFrame(() => this.draw());
}
}
export class CanvasUtils {
static canvasToPanZoomTransform(
x: number,
y: number,
canvas: [width: number, height: number],
useZoom: boolean
) {
let transformX = 0;
let transformY = 0;
if (useZoom) {
// CSS Zoom does not alter this (obviously)
transformX = canvas[0] / 2 - x;
transformY = canvas[1] / 2 - y;
} else {
transformX = canvas[0] / 2 - x;
transformY = canvas[1] / 2 - y;
}
return { transformX, transformY };
}
}
import EventEmitter from "eventemitter3";
type RCanvas = HTMLCanvasElement | OffscreenCanvas;
type RContext = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
export type CanvasPixel = {
x: number;
y: number;
hex: string;
};
const isWorker = () => {
return (
// @ts-ignore
typeof WorkerGlobalScope !== "undefined" &&
// @ts-ignore
// eslint-disable-next-line no-restricted-globals
self instanceof WorkerGlobalScope
);
};
export interface RendererEvents {
ready: () => void;
}
export type CanvasRole = "main" | "blank";
/**
* Generic renderer
*
* Can be instansiated inside worker or on the main thread
*/
export class CanvasRenderer extends EventEmitter<RendererEvents> {
private canvas: RCanvas = undefined as any;
private ctx: RContext = undefined as any;
private dimentions = {
width: 0,
height: 0,
};
private blank?: RCanvas;
private blank_ctx?: RContext;
/**
* Pixels that need to be drawn next draw call
*
* Key = x,y (eg 0,0)
*/
private pixels: Map<string, string> = new Map();
/**
* Every pixel
*
* Key = x,y (eg 0,0)
*/
private allPixels: Map<string, string> = new Map();
private isWorker = isWorker();
private _stopRender = false;
constructor() {
super();
console.log("[CanvasRenderer] Initialized", { isWorker: this.isWorker });
}
useCanvas(canvas: HTMLCanvasElement | OffscreenCanvas, role: CanvasRole) {
console.log("[CanvasRenderer] Received canvas reference for " + role);
let ctx = canvas.getContext("2d")! as any;
if (!ctx) {
throw new Error("Unable to get canvas context for " + role);
}
canvas.width = this.dimentions.width;
canvas.height = this.dimentions.height;
switch (role) {
case "main":
this.canvas = canvas;
this.ctx = ctx;
break;
case "blank":
this.blank = canvas;
this.blank_ctx = ctx;
break;
}
}
removeCanvas(role: CanvasRole) {
switch (role) {
case "main":
throw new Error("Cannot remove main canvas");
case "blank":
this.blank = undefined;
this.blank_ctx = undefined;
break;
}
}
usePixels(pixels: CanvasPixel[], _replace = false) {
for (const pixel of pixels) {
this.usePixel(pixel);
}
}
usePixel(pixel: CanvasPixel) {
let key = pixel.x + "," + pixel.y;
this.pixels.set(key, pixel.hex);
this.allPixels.set(key, pixel.hex);
}
startRender() {
console.log("[CanvasRenderer] Started rendering loop");
this._stopRender = false;
this.tryDrawFull();
this.tryDrawBlank();
this.renderLoop();
}
stopRender() {
console.log("[CanvasRenderer] Stopped rendering loop");
// used when not in worker
// kills the requestAnimationFrame loop
this._stopRender = true;
}
private tryDrawFull() {
if (this._stopRender) return;
if (this.ctx) {
this.drawFull();
} else {
requestAnimationFrame(() => this.tryDrawFull());
}
}
private tryDrawBlank() {
if (this._stopRender) return;
if (this.blank_ctx) {
this.drawBlank();
setTimeout(() => requestAnimationFrame(() => this.tryDrawBlank()), 1000);
} else {
requestAnimationFrame(() => this.tryDrawBlank());
}
}
private renderLoop() {
if (this._stopRender) return;
if (this.ctx) {
this.draw();
} else {
console.warn("[CanvasRenderer#renderLoop] has no canvas context");
}
requestAnimationFrame(() => this.renderLoop());
}
private drawTimes: number[] = [];
/**
* Draw canvas
*
* This should be done using differences
*/
draw() {
const start = performance.now();
const pixels = new Map(this.pixels);
this.pixels.clear();
if (pixels.size) {
console.log("[CanvasRenderer#draw] drawing " + pixels.size + " pixels");
}
for (const [x_y, hex] of pixels) {
const x = parseInt(x_y.split(",")[0]);
const y = parseInt(x_y.split(",")[1]);
this.ctx.fillStyle = hex === "null" ? "#fff" : "#" + hex;
this.ctx.fillRect(x, y, 1, 1);
}
const diff = performance.now() - start;
this.drawTimes = this.drawTimes.slice(0, 300);
const drawavg =
this.drawTimes.length > 0
? this.drawTimes.reduce((a, b) => a + b) / this.drawTimes.length
: 0;
if (diff > 0) this.drawTimes.push(diff);
if (diff > drawavg) {
console.warn(
`canvas#draw took ${diff} ms (> avg: ${drawavg} ; ${this.drawTimes.length} samples)`
);
}
}
/**
* fully draw canvas
*/
private drawFull() {
// --- main canvas ---
this.ctx.imageSmoothingEnabled = false;
this.ctx.globalAlpha = 1;
// clear canvas
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
for (const [x_y, hex] of this.allPixels) {
const x = parseInt(x_y.split(",")[0]);
const y = parseInt(x_y.split(",")[1]);
this.ctx.fillStyle = hex === "null" ? "#fff" : "#" + hex;
this.ctx.fillRect(x, y, 1, 1);
}
}
private drawBlank() {
if (this.blank && this.blank_ctx) {
// --- blank canvas ---
let canvas = this.blank;
let ctx = this.blank_ctx;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const [x_y, hex] of this.allPixels) {
if (hex !== "null") continue;
const x = parseInt(x_y.split(",")[0]);
const y = parseInt(x_y.split(",")[1]);
ctx.fillStyle = "rgba(0,140,0,0.5)";
ctx.fillRect(x, y, 1, 1);
}
}
}
setSize(width: number, height: number) {
console.log("[CanvasRenderer] Received size set", { width, height });
this.dimentions = { width, height };
if (this.canvas) {
this.canvas.width = width;
this.canvas.height = height;
}
if (this.blank) {
this.blank.width = width;
this.blank.height = height;
}
this.tryDrawFull();
this.emit("ready");
}
}
import EventEmitter from "eventemitter3";
import { EnforceObjectType } from "./utils";
interface IKeybind {
key: KeyboardEvent["code"] | "LCLICK" | "RCLICK" | "MCLICK" | "LONG_PRESS";
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,
},
{ key: "LONG_PRESS" },
],
TEMPLATE_MOVE: [
{
key: "LCLICK",
alt: true,
},
],
TOGGLE_TEMPLATE: [
{
key: "KeyT",
},
],
TOGGLE_BLANK: [
{
key: "KeyV", // legacy pxls keybind
},
{
key: "KeyB",
},
],
TOGGLE_HEATMAP: [
{
key: "KeyH",
},
],
TOGGLE_MOD_MENU: [
{
key: "KeyM",
},
],
DESELECT_COLOR: [
{
key: "Escape",
},
],
PICK_COLOR: [
{
key: "MCLICK",
},
],
});
class KeybindManager_ extends EventEmitter<{
[k in keyof typeof KEYBINDS]: (args: EmittedKeybind) => void;
}> {
constructor() {
super();
// setup listeners
document.addEventListener("keydown", this.handleKeydown, {
passive: false,
});
document.addEventListener("keyup", this.handleKeyup);
document.addEventListener("click", this.handleClick); // only gets triggered for left click
document.addEventListener("auxclick", (e) => {
if (e.button === 0) return; // left button still triggers this
this.handleClick(e);
});
}
destroy() {
// remove listeners
// this is global and doesn't depend on any elements, so this shouldn't need to be called
}
handleKeydown = (e: KeyboardEvent) => {
const blacklistedElements = ["INPUT"];
if (e.target instanceof HTMLElement) {
if (blacklistedElements.indexOf(e.target.tagName) > -1) {
return;
}
}
if (e.key === "Alt") e.preventDefault();
if (e.key === "Control") e.preventDefault();
if (e.key === "Shift") e.preventDefault();
};
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_, keybinds] of Object.entries(KEYBINDS)) {
const name: keyof typeof KEYBINDS = name_ as any;
const valid = keybinds.find((kb) => {
if (kb.key !== key.key) return false;
if (typeof kb.alt !== "undefined" && kb.alt !== key.alt) return false;
if (typeof kb.ctrl !== "undefined" && kb.ctrl !== key.ctrl)
return false;
if (typeof kb.meta !== "undefined" && kb.meta !== key.meta)
return false;
if (typeof kb.shift !== "undefined" && kb.shift !== key.shift)
return false;
return true;
});
if (!valid) continue;
this.emit(name, emit);
isHandled = true;
}
return isHandled;
}
getKeybind(key: keyof typeof KEYBINDS) {
return KEYBINDS[key];
}
getKeybinds() {
return { ...KEYBINDS };
}
}
export const KeybindManager = new KeybindManager_();
......@@ -4,18 +4,41 @@ import {
AuthSession,
ClientConfig,
ClientToServerEvents,
IAccountStanding,
Pixel,
ServerToClientEvents,
Subscription,
} from "@sc07-canvas/lib/src/net";
import { toast } from "react-toastify";
import { handleAlert, handleDismiss } from "./alerts";
import { Recaptcha } from "./recaptcha";
export interface INetworkEvents {
connected: () => void;
disconnected: () => void;
user: (user: AuthSession) => void;
standing: (standing: IAccountStanding) => void;
config: (user: ClientConfig) => void;
canvas: (pixels: string[]) => void;
canvas: (
start: [x: number, y: number],
end: [x: number, y: number],
pixels: string[]
) => void;
pixels: (data: { available: number }) => void;
pixelLastPlaced: (time: number) => void;
online: (count: number) => void;
pixel: (pixel: Pixel) => void;
square: (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => void;
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
heatmap: (heatmap: string) => void;
}
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
......@@ -23,73 +46,181 @@ type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
>[Extract<K, keyof INetworkEvents>];
class Network extends EventEmitter<INetworkEvents> {
socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
import.meta.env.VITE_API_HOST,
{
autoConnect: false,
withCredentials: true,
}
);
socket: Socket<ServerToClientEvents, ClientToServerEvents> = io("", {
autoConnect: false,
withCredentials: true,
reconnection: true,
});
private online_count = 0;
private sentEvents: {
private stateEvents: {
[key in keyof INetworkEvents]?: SentEventValue<key>;
} = {};
private canvasChunks: {
start: [number, number];
end: [number, number];
pixels: string[];
}[] = [];
constructor() {
super();
this.socket.on("user", (user: AuthSession) => {
this.socket.on("connect", () => {
console.log("Connected to server");
toast.success("Connected to server");
this.emit("connected");
});
this.socket.on("connect_error", (err) => {
// TODO: proper error handling
console.error("Failed to connect to server", err);
toast.error("Failed to connect: " + (err.message || err.name));
});
this.socket.on("disconnect", (reason, desc) => {
console.log("Disconnected from server", reason, desc);
toast.warn("Disconnected from server");
this.emit("disconnected");
});
this.socket.io.on("reconnect", (attempt) => {
console.log("Reconnected to server on attempt " + attempt);
});
this.socket.io.on("reconnect_attempt", (attempt) => {
console.log("Reconnect attempt " + attempt);
});
this.socket.io.on("reconnect_error", (err) => {
console.log("Reconnect error", err);
});
this.socket.io.on("reconnect_failed", () => {
console.log("Reconnect failed");
});
this.socket.on("recaptcha", (site_key) => {
Recaptcha.load(site_key);
});
this.socket.on("recaptcha_challenge", (ack) => {
Recaptcha.executeChallenge(ack);
});
this.socket.on("user", (user) => {
this.emit("user", user);
});
this.socket.on("standing", (standing) => {
this.acceptState("standing", standing);
});
this.socket.on("config", (config) => {
console.info("Server sent config", config);
if (config.version !== __COMMIT_HASH__) {
toast.info("Client version does not match server, reloading...");
console.warn("Client version does not match server, reloading...", {
clientVersion: __COMMIT_HASH__,
serverVersion: config.version,
});
window.location.reload();
}
this.emit("config", config);
});
this.socket.on("canvas", (pixels) => {
this._emit("canvas", pixels);
this.socket.on("canvas", (start, end, pixels) => {
// this.acceptState("canvas", start, end, pixels);
this.emit("canvas", start, end, pixels);
this.canvasChunks.push({ start, end, pixels });
});
this.socket.on("clearCanvasChunks", () => {
this.canvasChunks = [];
});
this.socket.on("availablePixels", (count) => {
this._emit("pixels", { available: count });
this.acceptState("pixels", { available: count });
});
this.socket.on("pixelLastPlaced", (time) => {
this._emit("pixelLastPlaced", time);
this.acceptState("pixelLastPlaced", time);
});
this.socket.on("online", ({ count }) => {
this._emit("online", count);
this.acceptState("online", count);
});
this.socket.on("pixel", (pixel) => {
this.emit("pixel", pixel);
});
// this.socket.on("config", (config) => {
// Pallete.load(config.pallete);
// Canvas.load(config.canvas);
// });
this.socket.on("square", (...square) => {
this.emit("square", ...square);
});
this.socket.on("undo", (undo) => {
this.emit("undo", undo);
});
this.socket.on("heatmap", (heatmap) => {
this.emit("heatmap", heatmap);
});
this.socket.on("alert", handleAlert);
this.socket.on("alert_dismiss", handleDismiss);
}
// this.socket.on("pixel", (data: SPixelPacket) => {
// Canvas.handlePixel(data);
// });
subscribe(subscription: Subscription) {
this.socket.emit("subscribe", subscription);
}
// this.socket.on("canvas", (data: SCanvasPacket) => {
// Canvas.handleBatch(data);
// });
unsubscribe(subscription: Subscription) {
this.socket.emit("unsubscribe", subscription);
}
private _emit: typeof this.emit = (event, ...args) => {
this.sentEvents[event] = args;
/**
* Track events that we only care about the most recent version of
*
* Used by #waitFor
*
* @param event
* @param args
* @returns
*/
acceptState: typeof this.emit = (event, ...args) => {
this.stateEvents[event] = args;
return this.emit(event, ...args);
};
waitFor<Ev extends keyof INetworkEvents & (string | symbol)>(
/**
* Discard the existing state-like event, if it exists in cache
* @param ev
*/
clearPreviousState<Ev extends keyof INetworkEvents & (string | symbol)>(
ev: Ev
) {
delete this.stateEvents[ev];
}
getCanvasChunks() {
return this.canvasChunks;
}
/**
* Wait for event, either being already sent, or new one
*
* Used for state-like events
*
* @param ev
* @returns
*/
waitForState<Ev extends keyof INetworkEvents & (string | symbol)>(
ev: Ev
): Promise<SentEventValue<Ev>> {
return new Promise((res) => {
if (this.sentEvents[ev]) return res(this.sentEvents[ev]!);
if (this.stateEvents[ev]) return res(this.stateEvents[ev]!);
this.once(ev, (...data) => {
res(data);
......@@ -97,6 +228,17 @@ class Network extends EventEmitter<INetworkEvents> {
});
}
/**
* Get current value of state event
* @param event
* @returns
*/
getState<Ev extends keyof INetworkEvents>(
event: Ev
): SentEventValue<Ev> | undefined {
return this.stateEvents[event];
}
/**
* Get online user count
* @returns online users count
......
class Recaptcha_ {
load(site_key: string) {
const script = document.createElement("script");
script.setAttribute(
"src",
`https://www.google.com/recaptcha/api.js?render=explicit`
);
document.head.appendChild(script);
script.onload = () => {
grecaptcha.ready(() => {
grecaptcha.render("grecaptcha-badge", {
sitekey: site_key,
badge: "inline",
size: "invisible",
});
console.log("Google Recaptcha Loaded!");
});
};
}
executeChallenge(ack: (token: string) => void) {
console.log("[Recaptcha] Received challenge request...");
grecaptcha.execute().then((token) => {
console.log("[Recaptcha] Sending challenge token back");
ack(token as any);
});
}
}
export const Recaptcha = new Recaptcha_();
import { toast } from "react-toastify";
import RenderWorker from "../worker/render.worker?worker";
import {
CanvasPixel,
CanvasRenderer,
CanvasRole,
RendererEvents,
} from "./canvasRenderer";
import { ExtractMethods } from "./utils";
import EventEmitter from "eventemitter3";
const hasWorkerSupport =
typeof Worker !== "undefined" && !localStorage.getItem("no_workers");
export abstract class Renderer
extends EventEmitter<RendererEvents>
implements ICanvasRenderer
{
hasWorker: boolean;
constructor(hasWorker: boolean) {
super();
this.hasWorker = hasWorker;
}
/**
* Get the renderer that is available to the client
* @returns
*/
static create(): Renderer {
if (hasWorkerSupport) {
return new WorkerRenderer();
} else {
return new LocalRenderer();
}
}
abstract usePixels(pixels: CanvasPixel[], replace?: boolean): void;
abstract usePixel(pixel: CanvasPixel): void;
abstract draw(): void;
abstract setSize(width: number, height: number): void;
abstract useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void;
abstract removeCanvas(role: CanvasRole): void;
abstract startRender(): void;
abstract stopRender(): void;
}
type ICanvasRenderer = Omit<
ExtractMethods<CanvasRenderer>,
"useCanvas" | keyof ExtractMethods<EventEmitter>
> & {
useCanvas: (canvas: HTMLCanvasElement, role: CanvasRole) => void;
};
class WorkerRenderer extends Renderer implements ICanvasRenderer {
private worker: Worker;
constructor() {
super(true);
this.worker = new RenderWorker();
this.worker.addEventListener("message", (req) => {
if (req.data.type === "ready") {
this.emit("ready");
}
});
}
destroy(): void {
console.warn("[WorkerRender#destroy] Destroying worker");
this.worker.terminate();
}
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
const offscreen = canvas.transferControlToOffscreen();
this.worker.postMessage({ type: "canvas", role, canvas: offscreen }, [
offscreen,
]);
}
removeCanvas(role: CanvasRole): void {
this.worker.postMessage({ type: "remove-canvas", role });
}
usePixels(pixels: CanvasPixel[], replace: boolean): void {
this.worker.postMessage({
type: "pixels",
replace,
pixels: pixels
.map((pixel) => pixel.x + "," + pixel.y + "," + pixel.hex)
.join(";"),
});
}
usePixel({ x, y, hex }: CanvasPixel): void {
this.worker.postMessage({
type: "pixel",
pixel: x + "," + y + "," + (hex || "null"),
});
}
startDrawLoop(): void {
throw new Error("Method not implemented.");
}
startRender(): void {
this.worker.postMessage({ type: "startRender" });
}
stopRender(): void {
this.worker.postMessage({ type: "stopRender" });
}
draw(): void {
this.worker.postMessage({ type: "draw" });
}
setSize(width: number, height: number): void {
this.worker.postMessage({ type: "size", width, height });
}
}
class LocalRenderer extends Renderer implements ICanvasRenderer {
reference: CanvasRenderer;
constructor() {
super(false);
toast.error(
"Your browser doesn't support WebWorkers, this will cause performance issues"
);
this.reference = new CanvasRenderer();
this.reference.on("ready", () => this.emit("ready"));
}
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
this.reference.useCanvas(canvas, role);
}
removeCanvas(role: CanvasRole) {
this.reference.removeCanvas(role);
}
usePixels(pixels: CanvasPixel[], replace: boolean): void {
this.reference.usePixels(pixels, replace);
}
usePixel(pixel: CanvasPixel): void {
this.reference.usePixel(pixel);
}
startRender(): void {
this.reference.startRender();
}
stopRender(): void {
this.reference.stopRender();
}
draw(): void {
this.reference.draw();
}
setSize(width: number, height: number): void {
this.reference.setSize(width, height);
}
}
import { PanZoom } from "@sc07-canvas/lib/src/renderer/PanZoom";
import { Canvas } from "./canvas";
import throttle from "lodash.throttle";
import EventEmitter from "eventemitter3";
import { TemplateStyle, TemplateStyles } from "./template";
const CLIENT_PARAMS = {
canvas_x: "x",
canvas_y: "y",
canvas_zoom: "zoom",
template_url: "tu",
template_width: "tw",
template_x: "tx",
template_y: "ty",
template_style: "ts",
};
export interface IRouterData {
canvas?: {
x: number;
y: number;
zoom?: number;
};
template?: {
url: string;
width?: number;
x?: number;
y?: number;
style?: TemplateStyle;
};
}
interface RouterEvents {
navigate(route: IRouterData): void;
}
class _Router extends EventEmitter<RouterEvents> {
PanZoom: PanZoom | undefined;
// React TemplateContext
templateState: {
enabled: boolean;
width?: number;
x: number;
y: number;
url?: string;
style: TemplateStyle;
} = {
enabled: false,
x: 0,
y: 0,
style: "ONE_TO_ONE",
};
constructor() {
super();
window.addEventListener("hashchange", this._hashChange.bind(this));
}
destroy() {
// NOTE: this method *never* gets called because this is intended to be global
window.removeEventListener("hashchange", this._hashChange.bind(this));
}
_hashChange(_e: HashChangeEvent) {
const data = this.get();
console.info("[Router] Navigated", data);
this.emit("navigate", data);
}
queueUpdate = throttle(this.update, 500);
update() {
const url = this.getURL();
if (!url) return;
console.log("[Router] Updating URL", url);
window.history.replaceState({}, "", url);
}
getURL() {
const canvas = Canvas.instance;
// this is not that helpful because the data is more spread out
// this gets replaced by using TemplateContext data
// const template = Template.instance;
if (!canvas) {
console.warn("Router#update called but no canvas instance exists");
return;
}
if (!this.PanZoom) {
console.warn("Router#update called but no PanZoom instance exists");
}
const params = new URLSearchParams();
const position = canvas.panZoomTransformToCanvas();
params.set(CLIENT_PARAMS.canvas_x, position.canvasX + "");
params.set(CLIENT_PARAMS.canvas_y, position.canvasY + "");
params.set(
CLIENT_PARAMS.canvas_zoom,
(this.PanZoom!.transform.scale >> 0) + ""
);
if (this.templateState.enabled && this.templateState.url) {
params.set(CLIENT_PARAMS.template_url, this.templateState.url + "");
if (this.templateState.width)
params.set(CLIENT_PARAMS.template_width, this.templateState.width + "");
params.set(CLIENT_PARAMS.template_x, this.templateState.x + "");
params.set(CLIENT_PARAMS.template_y, this.templateState.y + "");
if (this.templateState.style)
params.set(CLIENT_PARAMS.template_style, this.templateState.style + "");
}
return (
window.location.protocol + "//" + window.location.host + "/#" + params
);
}
/**
* Parse the URL and return what was found, following specifications
* There's no defaults, if it's not specified in the url, it's not specified in the return
*
* @returns
*/
get(): IRouterData {
const params = new URLSearchParams(window.location.hash.slice(1));
let canvas:
| {
x: number;
y: number;
zoom?: number;
}
| undefined = undefined;
if (
params.has(CLIENT_PARAMS.canvas_x) &&
params.has(CLIENT_PARAMS.canvas_y)
) {
// params exist, now to validate
// x & y or nothing; zoom is optional
let x = parseInt(params.get(CLIENT_PARAMS.canvas_x) || "");
let y = parseInt(params.get(CLIENT_PARAMS.canvas_y) || "");
if (!isNaN(x) && !isNaN(y)) {
// x & y are valid numbers
canvas = {
x,
y,
};
if (params.has(CLIENT_PARAMS.canvas_zoom)) {
let zoom = parseInt(params.get(CLIENT_PARAMS.canvas_zoom) || "");
if (!isNaN(zoom)) {
canvas.zoom = zoom;
}
}
}
}
let template:
| {
url: string;
width?: number;
x?: number;
y?: number;
style?: TemplateStyle;
}
| undefined = undefined;
if (params.has(CLIENT_PARAMS.template_url)) {
const url = params.get(CLIENT_PARAMS.template_url)!;
template = { url };
if (params.has(CLIENT_PARAMS.template_width)) {
let width = parseInt(params.get(CLIENT_PARAMS.template_width) || "");
if (!isNaN(width)) {
template.width = width;
}
}
if (
params.has(CLIENT_PARAMS.template_x) &&
params.has(CLIENT_PARAMS.template_y)
) {
// both x & y has to be set
let x = parseInt(params.get(CLIENT_PARAMS.template_x) || "");
let y = parseInt(params.get(CLIENT_PARAMS.template_y) || "");
if (!isNaN(x) && !isNaN(y)) {
template.x = x;
template.y = y;
}
}
if (params.has(CLIENT_PARAMS.template_style)) {
let style = params.get(CLIENT_PARAMS.template_style);
if (style && TemplateStyles.indexOf(style) > -1) {
template.style = style as any;
}
}
}
return {
canvas,
template,
};
}
/**
* Accept updates to local copy of TemplateContext from React
* @param args
*/
setTemplate(args: {
enabled: boolean;
width?: number;
x: number;
y: number;
url?: string;
style: TemplateStyle;
}) {
this.templateState = args;
}
}
export const Router = new _Router();
import { ICanvasPosition } from "../types";
export const Routes = {
canvas: (pos: ICanvasPosition) => {
const params = new URLSearchParams();
params.set("x", pos.x + "");
params.set("y", pos.y + "");
params.set("zoom", pos.zoom + "");
return "/#" + params;
},
};
import * as Sentry from "@sentry/react";
if (__SENTRY_DSN__) {
Sentry.init({
dsn: __SENTRY_DSN__,
environment: import.meta.env.MODE,
tunnel: "/api/_meta",
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
// Tracing
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: [
"localhost",
new RegExp(
"^" + window.location.protocol + "//" + window.location.host + "/api"
),
],
// Session Replay
replaysSessionSampleRate: 1.0, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
console.log("Sentry loaded with", __SENTRY_DSN__);
}
import EventEmitter from "eventemitter3";
import { WebGLUtils } from "./webgl";
import { ClientConfig } from "@sc07-canvas/lib/src/net";
import { rgbToHex } from "./utils";
interface TemplateEvents {
updateImageURL(url: string | undefined): void;
option<T extends keyof ITemplateOptions>(
option: T,
value: ITemplateOptions[T]
): void;
autoDetectWidth(width: number): void;
}
interface ITemplateOptions {
enable: boolean;
width?: number;
style: keyof typeof TemplateStyle;
}
const TemplateStyle = {
SOURCE: "",
ONE_TO_ONE:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
ONE_TO_ONE_INCORRECT:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
DOTTED_SMALL:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAAmJLR0QA/4ePzL8AAAAzSURBVFjD7dBBDQAACMSw828aVEAI6R4T0GShGv6DECFChAgRIkSIECFChAgRIkSIruA0nub+AuTzLZoAAAAASUVORK5CYII=",
DOTTED_BIG:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAWklEQVR42u3UwQkAIAwEwcX+e9aP2INkBvK4d2CLqva9cXv5PWgAoAGgARoAGqABoAEaABqgAaABGgAaoAGgARoAGqABoAEaABqgAaABGgAaoAGgAT/vRwOmO8dS/DI1VxCbAAAAAElFTkSuQmCC",
SYMBOLS:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHAAAABwAQMAAAD8LmYIAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAZQTFRFAAAAAwMDFQUF7wAAAAJ0Uk5TAP9bkSK1AAAAuUlEQVR4nGNgQAUhjQvdz0uwMfx82OrVIgPkBj/xaOEQ6GRuP9vHAeQGsPjzyVj8LH5+iAXEDTziMd+uplHg+VE+GQaNjwHt5+WB3A+HO+bbMRACDoed+Xg0FIMW97dIMLAwNC45OF8ip+Dh8aN8Ngwsjc2sXfNFBAoePz8xX46B5+DhNj4WlpwCx+NH5W0Yan5+fn6+xU5DwWlxf58EAWs0DFC4NQX4uBaoXAFUvaNgFIyCUTAKaAYAzI49GM5w0hQAAAAOZVhJZk1NACoAAAAIAAAAAAAAANJTkwAAAABJRU5ErkJggg==",
NUMBERS:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALAAAACwCAYAAACvt+ReAAAE10lEQVR42u3d3bKjKBQGULDy/q/MXE1VdzqRH9kqZq2bqZn5JKAmErbm5JRSSbCw0pGTlb1VdttpoAS9AUpANgW1u0q2dGxTOo7faLu92TTa7vZlozz55P2/zRyQ7RXRj97+zsyWjm1GT97aGI9kR8aWaydwxIEg7g05ms0nvcnCsq8bzrVnvVNr2RyQzReP7eeO33bBp0We/E6NnJr0nJAR7UZOpR5x/LYEC9smrCyMrETMXErpvazd4fI9c3+VnW/2teze8Ss7qwAt7ZYJ50y13deqk/fBbVYb28iY8mLZvf9ebTcnlTgeOIWAZShJyi6bfX3YOH84sfOXF7oyW3amQrXs++vMarc3m7/048w+rJT957htlU/i3HCQ93J77R7N5o4vD+/ZUvmSkRvHdiSbOvqwt/2RbA7av6cdt+0Bqw8jlMDX9M9xq5WS71xKjS5VtmxbDvZ3JJsDsvnEsU09dq+GM75MPnl72s2VQZx1JehdA23pb8/YevdDax/KhWMrM84Vy2gs7dOXuJGSZMslYLTUWbsUtbT7nm25ibqlhPqp3Z7+po7+RuyHnj707t/S8fql8/XLyHzE2qPs7bJKyTxmCgFLcimSXTa7fdiwfPn3NDGbgtq9ezYNZke++JaAbApqdzj75zrw+9rd3lrekeye1vsljmZ7+5snZL/1q2clJw3uwxnZXlGPWP3VX3PgNSh9f/HaeaeXzk+FEpzNAdl88dhSQPanjttWeafX7lZq/ZRovQPqSLanDyWo3ci70XqyvXeutbQbeVdez91onkrmmVOII3c1RV02I+8Ei2g36sc/SuOVo+WSfKS/EdOfw/2wnii7bFYpmaWZA7M8lyLZZbOvD0sUf/4z7XyJ68n++f88PfyDTw9H9WHWI0W17JFHXmqv+WnHzcymjj7Utj2yvpwC9u/yx+3uc2Al1DWddtxelfnw7DJjxI9Kt14pSuM7flY2B2TzxWO73XF7/12IM8qMtXeuEmpDCfWEsR2dSvVOu4ZuWbCMxtJaf9gkHcjNKM3WVgBqlzGl7/7+HhlfrfQ9ejdaOXqSysreKquUzNLMgVmeS5Hsstlv9wMroY5lW7+4KH1Pyr6vQiihHnsquTSMy1Pf4/v3n6w58FxK3yf7VkpWQo35M7Ol4xPzvd0SnM0B2Rw9tq1y+f7Fp4fPOHlr/SgdYysHxta7H3pOyIh2/a1kfmMK0fqJ0rrd3Uq5nh6O3Q8peP8Obywre6usUjJLMwdmeS5Fsstma6Xkb8scSqjPyC5/3Fp+nfKbI0+hRq0vp45s72MsOaC/V2eXP26z5sBKqGta/rjNWgfuyfrh7Pix/cxx2w68Iy95CvWiS5wfzt7f/rKnvi2j8egpxC2fQr355TCiXU9972xrPVF22axSMo+aQkCUsCU7lyLZM7Lhn8BKqOf39xdL31PN+kOHSqhj+yF1ju0ppe+wE9h8jKW/xK1WQj1D5GM3I9mIH5vOF49tyifwij/AfOYndk8JNqLNiDJ/CWr3tOOmlMxjphB+gPn4VErp+4Jpn3VK2TOyYXM7pWTO+h4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE/1H4IIqRgL4W2oAAAAAElFTkSuQmCC",
};
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type TemplateStyle = keyof typeof TemplateStyle;
export const TemplateStyles = Object.keys(TemplateStyle);
const STYLES_Y = 16;
const STYLES_X = 16;
export class Template extends EventEmitter<TemplateEvents> {
static instance: Template;
config: ClientConfig;
$wrapper: HTMLDivElement;
$imageLoader: HTMLImageElement;
$style: HTMLImageElement;
$canvas: HTMLCanvasElement;
imageURL: string | undefined;
options: ITemplateOptions = {
enable: false,
style: "ONE_TO_ONE",
};
constructor(config: ClientConfig, templateHolder: HTMLDivElement) {
super();
Template.instance = this;
this.config = config;
console.log("[Template] Initialize", config, templateHolder);
this.$wrapper = templateHolder;
this.$imageLoader = document.createElement("img");
this.$imageLoader.style.setProperty("display", "none");
this.$imageLoader.setAttribute("crossorigin", "");
this.$imageLoader.addEventListener("load", () => {
console.log("[Template] Image loaded");
if (!this.options.width) {
this.setOption("width", this.$imageLoader.naturalWidth);
this.emit("autoDetectWidth", this.$imageLoader.naturalWidth);
}
this.rasterizeTemplate();
});
this.$style = document.createElement("img");
this.$style.setAttribute("crossorigin", "");
this.$style.setAttribute("src", TemplateStyle[this.options!.style]);
this.$style.addEventListener("load", () => {
console.log("[Template] Style loaded");
this.loadStyle();
});
this.$canvas = document.createElement("canvas");
[this.$imageLoader, this.$style, this.$canvas].forEach((el) =>
el.classList.add("pixelate")
);
templateHolder.style.width = this.options!.width + "px";
templateHolder.appendChild(this.$imageLoader);
// templateHolder.appendChild(this.$style);
templateHolder.appendChild(this.$canvas);
this.setupWebGL();
}
destroy() {
this.$imageLoader.remove();
this.$style.remove();
this.$canvas.remove();
this.removeAllListeners();
}
/**
* Update settings
*
* NOTE: this does not cause re-render
*
* @param key
* @param value
*/
setOption<T extends keyof ITemplateOptions>(
key: T,
value: ITemplateOptions[T]
) {
this.options[key] = value;
switch (key) {
case "enable":
this.setElementVisible([this.$canvas], !!value);
break;
case "style":
if ((value as keyof typeof TemplateStyle) in TemplateStyle) {
const key = value as keyof typeof TemplateStyle;
this.$style.setAttribute("src", TemplateStyle[key]);
this.$imageLoader.style.display = key === "SOURCE" ? "block" : "none";
if (key === "SOURCE") {
this.stylizeTemplate();
}
}
break;
}
this.emit("option", key, value);
}
setElementVisible(els: HTMLElement[], visible: boolean) {
for (const el of els) {
el.style.display = visible ? "block" : "none";
}
}
getPixel(x: number, y: number): string | undefined {
if (!this.context) {
console.warn("[Template#getPixel] No context is available");
return undefined;
}
const width = this.context.drawingBufferWidth;
const height = this.context.drawingBufferHeight;
const arr = new Uint8Array(4 * width * height);
this.context.bindFramebuffer(
this.context.FRAMEBUFFER,
this.framebuffers.intermediate
);
if (x < 0 || y < 0 || x > width || y > height) {
return undefined;
}
this.context.readPixels(
0,
0,
width,
height,
this.context.RGBA,
this.context.UNSIGNED_BYTE,
arr
);
this.context.bindFramebuffer(
this.context.FRAMEBUFFER,
this.framebuffers.main
);
const pixels = new Uint8Array(4 * width * height);
const length = width * height * 4;
const row = width * 4;
const end = (height - 1) * row;
for (let i = 0; i < length; i += row) {
pixels.set(arr.subarray(i, i + row), end - i);
}
const [r, g, b, a] = pixels.slice(
4 * (y * this.context.drawingBufferWidth + x),
4 * (y * this.context.drawingBufferWidth + x) + 4
);
if (a === 254) return undefined;
return rgbToHex(r, g, b);
}
rasterizeTemplate() {
this.downscaleTemplate();
this.stylizeTemplate();
}
loadImage(url: string) {
return fetch(url, { method: "GET", credentials: "omit" })
.then((resp) => {
if (resp.ok) {
return resp.blob();
} else {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
})
.then((blob) => {
return new Promise<void>((res) => {
const reader = new FileReader();
reader.onload = () => {
// reader.result will be a string because of reader.readAsDataURL being called
this.imageURL = reader.result as string;
this.$imageLoader.setAttribute("src", this.imageURL);
this.emit("updateImageURL", this.imageURL);
res();
};
reader.readAsDataURL(blob);
});
})
.catch((err) => {
// TODO: Better error handling
alert("template loadimage error: " + err);
});
}
getDimentions() {
let source = {
width: this.$imageLoader.naturalWidth,
height: this.$imageLoader.naturalHeight,
};
let style = {
width: this.$style.naturalWidth / STYLES_X,
height: this.$style.naturalHeight / STYLES_Y,
};
let aspectRatio = source.height / source.width;
let display = {
width: Math.round(this.options?.width || source.width),
height: Math.round((this.options?.width || source.width) * aspectRatio),
};
let internal = {
width: display.width * style.width,
height: display.height * style.height,
};
return {
source,
style,
display,
internal,
aspectRatio,
};
}
private context: WebGLRenderingContext | undefined;
private textures: {
source: WebGLTexture | null;
downscaled: WebGLTexture | null;
style: WebGLTexture | null;
} = {} as any;
private framebuffers: {
intermediate: WebGLFramebuffer | null;
main: WebGLFramebuffer | null;
} = {} as any;
private buffers: { vertex: WebGLBuffer | null } = {} as any;
private programs: {
downscaling: { unconverted: WebGLProgram; nearestCustom: WebGLProgram };
stylize: WebGLProgram;
} = { downscaling: {} } as any;
updateSize() {
const { display, internal } = this.getDimentions();
this.$wrapper.style.width = display.width + "px";
this.$imageLoader.style.width = display.width + "px";
this.$canvas.style.width = display.width + "px";
this.$canvas.width = internal.width;
this.$canvas.height = internal.height;
}
/**
* Initialize webgl
*
* This originates from Pxls and their contributors
* https://github.com/pxlsspace/pxls-web/blob/0c5a680c4611af277205886df77ac9014c759aba/public/include/template.js#L616
*/
setupWebGL() {
const palette: { value: string }[] = this.config.pallete.colors.map(
(color) => ({ value: color.hex })
);
const context = this.$canvas.getContext("webgl", {
premultipliedAlpha: true,
});
if (context === null) {
throw new Error("WebGL is not supported");
}
this.context = context;
const utils = new WebGLUtils(context);
context.clearColor(0, 0, 0, 0);
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
this.textures.source = utils.createTexture();
this.textures.downscaled = utils.createTexture();
this.framebuffers.intermediate = context.createFramebuffer();
context.bindFramebuffer(
context.FRAMEBUFFER,
this.framebuffers.intermediate
);
context.framebufferTexture2D(
context.FRAMEBUFFER,
context.COLOR_ATTACHMENT0,
context.TEXTURE_2D,
this.textures.downscaled,
0
);
this.textures.style = utils.createTexture();
this.loadStyle(false);
this.buffers.vertex = context.createBuffer();
context.bindBuffer(context.ARRAY_BUFFER, this.buffers.vertex);
// prettier-ignore
context.bufferData(
context.ARRAY_BUFFER,
new Float32Array([
-1, -1,
-1, 1,
1, -1,
1, 1
]),
context.STATIC_DRAW
);
const identityVertexShader = `
attribute vec2 a_Pos;
varying vec2 v_TexCoord;
void main() {
v_TexCoord = a_Pos * vec2(0.5, 0.5) + vec2(0.5, 0.5);
gl_Position = vec4(a_Pos, 0.0, 1.0);
}
`;
const paletteDefs = `
#define PALETTE_LENGTH ${palette.length}
#define PALETTE_MAXSIZE 255.0
#define PALETTE_TRANSPARENT (PALETTE_MAXSIZE - 1.0) / PALETTE_MAXSIZE
#define PALETTE_UNKNOWN 1.0
`;
const diffCustom = `
#define LUMA_WEIGHTS vec3(0.299, 0.587, 0.114)
// a simple custom colorspace that stores:
// - brightness
// - red/green-ness
// - blue/yellow-ness
// this storing of contrasts is similar to how humans
// see color difference and provides a simple difference function
// with decent results.
vec3 rgb2Custom(vec3 rgb) {
return vec3(
length(rgb * LUMA_WEIGHTS),
rgb.r - rgb.g,
rgb.b - (rgb.r + rgb.g) / 2.0
);
}
float diffCustom(vec3 col1, vec3 col2) {
return length(rgb2Custom(col1) - rgb2Custom(col2));
}
`;
const downscalingFragmentShader = (comparisonFunctionName?: string) => `
precision mediump float;
// GLES (and thus WebGL) does not support dynamic for loops
// the workaround is to specify the condition as an upper bound
// then break the loop early if we reach our dynamic limit
#define MAX_SAMPLE_SIZE 16.0
${paletteDefs}
${comparisonFunctionName !== undefined ? "#define CONVERT_COLORS" : ""}
#define HIGHEST_DIFF 999999.9
uniform sampler2D u_Template;
uniform vec2 u_TexelSize;
uniform vec2 u_SampleSize;
uniform vec3 u_Palette[PALETTE_LENGTH];
varying vec2 v_TexCoord;
const float epsilon = 1.0 / 128.0;
// The alpha channel is used to index the palette:
const vec4 transparentColor = vec4(0.0, 0.0, 0.0, PALETTE_TRANSPARENT);
${diffCustom}
void main () {
vec4 color = vec4(0.0);
vec2 actualSampleSize = min(u_SampleSize, vec2(MAX_SAMPLE_SIZE));
vec2 sampleTexSize = u_TexelSize / actualSampleSize;
// sample is taken from center of fragment
// this moves the coordinates to the starting corner and to the center of the sample texel
vec2 sampleOrigin = v_TexCoord - sampleTexSize * (actualSampleSize / 2.0 - 0.5);
float sampleCount = 0.0;
for(float x = 0.0; x < MAX_SAMPLE_SIZE; x++) {
if(x >= u_SampleSize.x) {
break;
}
for(float y = 0.0; y < MAX_SAMPLE_SIZE; y++) {
if(y >= u_SampleSize.y) {
break;
}
vec2 pos = sampleOrigin + sampleTexSize * vec2(x, y);
vec4 sample = texture2D(u_Template, pos);
// pxlsfiddle uses the alpha channel of the first pixel to store
// scale information. This can affect color sampling, so drop the
// top-left-most subtexel unless its alpha is typical (1 or 0 exactly).
if(x == 0.0 && y == 0.0
&& pos.x < u_TexelSize.x && (1.0 - pos.y) < u_TexelSize.y
&& sample.a != 1.0) {
continue;
}
if(sample.a == 0.0) {
continue;
}
color += sample;
sampleCount++;
}
}
if(sampleCount == 0.0) {
gl_FragColor = transparentColor;
return;
}
color /= sampleCount;
#ifdef CONVERT_COLORS
float bestDiff = HIGHEST_DIFF;
int bestIndex = int(PALETTE_MAXSIZE);
vec3 bestColor = vec3(0.0);
for(int i = 0; i < PALETTE_LENGTH; i++) {
float diff = ${comparisonFunctionName}(color.rgb, u_Palette[i]);
if(diff < bestDiff) {
bestDiff = diff;
bestIndex = i;
bestColor = u_Palette[i];
}
}
gl_FragColor = vec4(bestColor, float(bestIndex) / PALETTE_MAXSIZE);
#else
for(int i = 0; i < PALETTE_LENGTH; i++) {
if(all(lessThan(abs(u_Palette[i] - color.rgb), vec3(epsilon)))) {
gl_FragColor = vec4(u_Palette[i], float(i) / PALETTE_MAXSIZE);
return;
}
}
gl_FragColor = vec4(color.rgb, PALETTE_UNKNOWN);
#endif
}
`;
this.programs.downscaling.unconverted = utils.createProgram(
identityVertexShader,
downscalingFragmentShader()
);
this.programs.downscaling.nearestCustom = utils.createProgram(
identityVertexShader,
downscalingFragmentShader("diffCustom")
);
const int2rgb = (i: number) => [
(i >> 16) & 0xff,
(i >> 8) & 0xff,
i & 0xff,
];
const paletteBuffer = new Float32Array(
palette.flatMap((c) => int2rgb(parseInt(c.value, 16)).map((c) => c / 255))
);
for (const program of Object.values(this.programs.downscaling)) {
context.useProgram(program);
const posLocation = context.getAttribLocation(program, "a_Pos");
context.vertexAttribPointer(posLocation, 2, context.FLOAT, false, 0, 0);
context.enableVertexAttribArray(posLocation);
context.uniform1i(context.getUniformLocation(program, "u_Template"), 0);
context.uniform3fv(
context.getUniformLocation(program, "u_Palette"),
paletteBuffer
);
}
this.programs.stylize = utils.createProgram(
identityVertexShader,
`
precision mediump float;
#define STYLES_X float(${STYLES_X})
#define STYLES_Y float(${STYLES_Y})
${paletteDefs}
uniform sampler2D u_Template;
uniform sampler2D u_Style;
uniform vec2 u_TexelSize;
varying vec2 v_TexCoord;
const vec2 styleSize = vec2(1.0 / STYLES_X, 1.0 / STYLES_Y);
void main () {
vec4 templateSample = texture2D(u_Template, v_TexCoord);
float index = floor(templateSample.a * PALETTE_MAXSIZE + 0.5);
vec2 indexCoord = vec2(mod(index, STYLES_X), STYLES_Y - floor(index / STYLES_Y) - 1.0);
vec2 subTexCoord = mod(v_TexCoord, u_TexelSize) / u_TexelSize;
vec2 styleCoord = (indexCoord + subTexCoord) * styleSize;
vec4 styleMask = vec4(1.0, 1.0, 1.0, texture2D(u_Style, styleCoord).a);
if (texture2D(u_Style, styleCoord).a == 1.0) {
gl_FragColor = vec4(templateSample.rgb, templateSample.a == PALETTE_TRANSPARENT ? 0.0 : 1.0) * styleMask;
} else {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
}
}
`
);
context.useProgram(this.programs.stylize);
const stylePosLocation = context.getAttribLocation(
this.programs.stylize,
"a_Pos"
);
context.vertexAttribPointer(
stylePosLocation,
2,
context.FLOAT,
false,
0,
0
);
context.enableVertexAttribArray(stylePosLocation);
context.uniform1i(
context.getUniformLocation(this.programs.stylize, "u_Template"),
0
);
context.uniform1i(
context.getUniformLocation(this.programs.stylize, "u_Style"),
1
);
}
loadStyle(redraw = true) {
if (
this.context &&
this.$style.naturalWidth !== 0 &&
this.$style.naturalHeight !== 0
) {
this.context.activeTexture(this.context.TEXTURE1);
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
this.context.texImage2D(
this.context.TEXTURE_2D,
0,
this.context.ALPHA,
this.context.ALPHA,
this.context.UNSIGNED_BYTE,
this.$style
);
if (redraw) {
this.stylizeTemplate();
}
}
}
downscaleTemplate() {
const dimentions = this.getDimentions();
const {
display: { width, height },
} = dimentions;
if (!this.context || width === 0 || height === 0) return;
const downscaleWidth = dimentions.source.width / dimentions.display.width;
const downscaleHeight =
dimentions.source.height / dimentions.display.height;
// set size of framebuffer
this.context.activeTexture(this.context.TEXTURE0);
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
this.context.texImage2D(
this.context.TEXTURE_2D,
0,
this.context.RGBA,
width,
height,
0,
this.context.RGBA,
this.context.UNSIGNED_BYTE,
null
);
this.context.bindFramebuffer(
this.context.FRAMEBUFFER,
this.framebuffers.intermediate
);
this.context.clear(this.context.COLOR_BUFFER_BIT);
this.context.viewport(0, 0, width, height);
const program = this.programs.downscaling.nearestCustom;
this.context.useProgram(program);
this.context.uniform2f(
this.context.getUniformLocation(program, "u_SampleSize"),
Math.max(1, downscaleWidth),
Math.max(1, downscaleHeight)
);
this.context.uniform2f(
this.context.getUniformLocation(program, "u_TexelSize"),
1 / width,
1 / height
);
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.source);
this.context.texImage2D(
this.context.TEXTURE_2D,
0,
this.context.RGBA,
this.context.RGBA,
this.context.UNSIGNED_BYTE,
this.$imageLoader
);
this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
}
stylizeTemplate() {
this.updateSize();
const { internal, display } = this.getDimentions();
if (this.context == null || internal.width === 0 || internal.height === 0) {
return;
}
this.context.bindFramebuffer(
this.context.FRAMEBUFFER,
this.framebuffers.main
);
this.context.clear(this.context.COLOR_BUFFER_BIT);
this.context.viewport(0, 0, internal.width, internal.height);
this.context.useProgram(this.programs.stylize);
this.context.uniform2f(
this.context.getUniformLocation(this.programs.stylize, "u_TexelSize"),
1 / display.width,
1 / display.height
);
this.context.activeTexture(this.context.TEXTURE0);
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
this.context.activeTexture(this.context.TEXTURE1);
this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
}
}
import { toast } from "react-toastify";
import { Renderer } from "./renderer";
import { Debug } from "@sc07-canvas/lib/src/debug";
let _renderer: Renderer;
/**
* Get the renderer instance or create one
* @returns
*/
export const getRenderer = (): Renderer => {
if (_renderer) return _renderer;
_renderer = Renderer.create();
return _renderer;
};
Debug._getRenderer = getRenderer;
export const rgbToHex = (r: number, g: number, b: number) => {
function componentToHex(c: number) {
var hex = c.toString(16);
return hex.length === 1 ? "0" + hex : hex;
}
return (
"#" +
componentToHex(r) +
componentToHex(g) +
componentToHex(b)
).toUpperCase();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = unknown, Error = string>(
endpoint: string,
method: "GET" | "POST" | "PUT" = "GET",
body?: unknown
): Promise<{
status: number;
data: ({ success: true } & T) | { success: false; error: Error };
}> => {
const req = await fetch(endpoint, {
method,
credentials: "include",
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
},
body: JSON.stringify(body),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = await req.json();
} catch (_e) {
/* empty */
}
return {
status: req.status,
data,
};
};
export type PickMatching<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
export type ExtractMethods<T> = PickMatching<T, Function>;
export type EnforceObjectType<T> = <V extends { [k: string]: T }>(
v: V
) => { [k in keyof V]: T };
export const handleError = (api_response: Awaited<ReturnType<typeof api>>) => {
toast.error(
`Error: [${api_response.status}] ` +
("error" in api_response.data ? api_response.data.error : "Unknown Error")
);
};
/**
* Utilities for WebGL contexts
*/
export class WebGLUtils {
context: WebGLRenderingContext;
constructor(context: WebGLRenderingContext) {
this.context = context;
}
/**
* Create WebGL texture
*
* Originates from Pxls
*
* @returns WebGL texture
*/
createTexture() {
const texture = this.context.createTexture();
this.context.bindTexture(this.context.TEXTURE_2D, texture);
this.context.texParameteri(
this.context.TEXTURE_2D,
this.context.TEXTURE_WRAP_S,
this.context.CLAMP_TO_EDGE
);
this.context.texParameteri(
this.context.TEXTURE_2D,
this.context.TEXTURE_WRAP_T,
this.context.CLAMP_TO_EDGE
);
this.context.texParameteri(
this.context.TEXTURE_2D,
this.context.TEXTURE_MIN_FILTER,
this.context.NEAREST
);
this.context.texParameteri(
this.context.TEXTURE_2D,
this.context.TEXTURE_MAG_FILTER,
this.context.NEAREST
);
return texture;
}
/**
* Create WebGL program
*
* Originates from Pxls
*
* @param vertexSource
* @param fragmentSource
* @returns WebGL program
*/
createProgram(vertexSource: string, fragmentSource: string) {
// i believe null is only returned when webgl context is destroyed?
// maybe add proper handling here
const program = this.context.createProgram()!;
this.context.attachShader(
program,
this.createShader(this.context.VERTEX_SHADER, vertexSource)
);
this.context.attachShader(
program,
this.createShader(this.context.FRAGMENT_SHADER, fragmentSource)
);
this.context.linkProgram(program);
if (!this.context.getProgramParameter(program, this.context.LINK_STATUS)) {
throw new Error(
`Failed to link WebGL template program:\n\n${this.context.getProgramInfoLog(program)}`
);
}
return program;
}
/**
* Create WebGL shader
*
* Originates from Pxls
*
* @param type
* @param source
* @returns WebGL shader
*/
createShader(type: number, source: string) {
// i believe null is only returned when webgl context is destroyed?
// maybe add proper handling here
const shader = this.context.createShader(type)!;
this.context.shaderSource(shader, source);
this.context.compileShader(shader);
if (!this.context.getShaderParameter(shader, this.context.COMPILE_STATUS)) {
throw new Error(
`Failed to compile WebGL template shader:\n\n${this.context.getShaderInfoLog(shader)}`
);
}
return shader;
}
}
......@@ -10,8 +10,6 @@ html,
body {
overscroll-behavior: contain;
touch-action: none;
background-color: #ddd !important;
}
header#main-header {
......@@ -23,7 +21,7 @@ header#main-header {
display: flex;
flex-direction: row;
box-sizing: border-box;
z-index: 9999;
z-index: 40;
touch-action: none;
pointer-events: none;
......@@ -83,14 +81,18 @@ header#main-header {
z-index: 2;
}
#canvas-meta {
.toolbar-box {
position: absolute;
transform: translateY(-100%);
}
#canvas-meta {
top: -10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
flex-direction: column;
......@@ -142,5 +144,76 @@ main {
}
}
@import "./components/Pallete.scss";
.sidebar {
position: fixed;
top: 0;
z-index: 9998;
height: 100%;
min-width: 20rem;
max-width: 75vw;
box-shadow: 0 0 5rem rgba(0, 0, 0, 0.5);
overflow-y: auto;
&-right {
right: 0;
}
&-left {
left: 0;
}
header {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
background-color: rgba(0, 0, 0, 0.05);
padding: 5px;
h1,
h2 {
padding: 0;
margin: 0;
font-weight: 600;
}
h1 {
font-size: 1.2rem;
}
h2 {
font-size: 1rem;
}
}
section {
display: flex;
flex-direction: column;
gap: 5px;
padding: 5px 1rem;
> div {
input {
margin-right: 5px;
}
}
}
}
@keyframes pixel-pulse {
from {
width: 50px;
height: 50px;
transform: translate(-24.5px, -24.5px);
}
to {
width: 10px;
height: 10px;
transform: translate(-4.5px, -4.5px);
}
}
@import "./components/Toolbar/Palette.scss";
@import "./components/Templating/Template.scss";
@import "./board.scss";
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_HOST: string;
readonly VITE_INCLUDE_EVENT_INFO: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare const __COMMIT_HASH__: string;
declare const __SENTRY_DSN__: string | null;
/**
* Worker to handle canvas draws to free the main thread
*/
import { CanvasPixel, CanvasRenderer, CanvasRole } from "../lib/canvasRenderer";
console.log("[Render Worker] Initialize");
const renderer = new CanvasRenderer();
renderer.on("ready", () => {
postMessage({ type: "ready" });
});
// this is a worker context, window is not available here
// eslint-disable-next-line no-restricted-globals
addEventListener("message", (req) => {
switch (req.data.type) {
case "canvas": {
const canvas: OffscreenCanvas = req.data.canvas;
const role: CanvasRole = req.data.role;
renderer.useCanvas(canvas, role);
renderer.renderLoop();
break;
}
case "remove-canvas": {
const role: CanvasRole = req.data.role;
renderer.removeCanvas(role);
break;
}
case "size": {
const width: number = req.data.width;
const height: number = req.data.height;
renderer.setSize(width, height);
break;
}
case "pixels": {
const pixelsIn: string = req.data.pixels;
const replace: boolean = req.data.replace;
const pixels = deserializePixels(pixelsIn);
renderer.usePixels(pixels, replace);
break;
}
case "pixel": {
const pixel = deserializePixel(req.data.pixel);
renderer.usePixel(pixel);
break;
}
case "startRender": {
renderer.startRender();
break;
}
case "stopRender": {
renderer.stopRender();
break;
}
default:
console.warn(
"[Render Worker] Received unknown message type",
req.data.type
);
}
});
const deserializePixel = (str: string): CanvasPixel => {
let [x, y, hex] = str.split(",");
return {
x: parseInt(x),
y: parseInt(y),
hex,
};
};
const deserializePixels = (str: string): CanvasPixel[] => {
let pixels: CanvasPixel[] = [];
const pixelsIn = str.split(";");
for (const pixel of pixelsIn) {
pixels.push(deserializePixel(pixel));
}
return pixels;
};
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import * as child from "child_process";
import { sentryVitePlugin } from "@sentry/vite-plugin";
export default defineConfig({
root: "src",
envDir: "..",
build: {
outDir: "../dist",
emptyOutDir: true,
},
plugins: [
react({
include: "**/*.{jsx,tsx}",
}),
],
const commitHash = child
.execSync("git rev-parse --short HEAD")
.toString()
.trim();
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), "") };
return {
root: "src",
envDir: "..",
build: {
outDir: "../dist",
emptyOutDir: true,
sourcemap: true,
},
plugins: [
react({
include: "**/*.{jsx,tsx}",
}),
process.env.SENTRY_DSN ? sentryVitePlugin() : undefined,
],
define: {
__COMMIT_HASH__: JSON.stringify(commitHash),
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN) || null,
},
server: {
proxy: {
"/api": "http://localhost:3000",
"/socket.io": {
target: "ws://localhost:3000",
ws: true,
},
},
},
};
});
......@@ -9,13 +9,6 @@ export const CanvasLib = new (class {
* @returns Seconds to take to give the pixel
*/
getPixelCooldown(pixelNumber: number, config: ClientConfig) {
return pixelNumber * config.canvas.pixel.cooldown;
// const factorial = (n: number) => (n == 0 ? 1 : n * factorial(n - 1));
// return (
// config.canvas.pixel.cooldown *
// config.canvas.pixel.multiplier *
// (2 + pixelNumber + factorial(pixelNumber))
// );
return config.pallete.pixel_cooldown / 1000;
}
})();
import EventEmitter from "eventemitter3";
interface DebugEvents {
openTools(): void;
}
interface DebugArgs {
point: [x: number, y: number, id?: string];
text: [str: any];
}
export enum FlagCategory {
"Renderer",
"DebugMessages",
"Uncategorized",
}
class ExperimentFlag {
id: string;
enabled: boolean;
category: FlagCategory = FlagCategory.Uncategorized;
constructor(id: string, defaultEnabled = false, category?: FlagCategory) {
this.id = id;
this.enabled = defaultEnabled;
if (category) this.category = category;
}
}
interface FlagEvents {
enable(flag_id: string): void;
disable(flag_id: string): void;
}
class FlagManager extends EventEmitter<FlagEvents> {
flags: ExperimentFlag[];
constructor() {
super();
this.flags = [];
this.register(
// RENDERER
new ExperimentFlag(
"PANZOOM_PINCH_TRANSFORM_1",
false,
FlagCategory.Renderer
),
new ExperimentFlag(
"PANZOOM_PINCH_TRANSFORM_2",
false,
FlagCategory.Renderer
),
// DEBUG MESSAGES
new ExperimentFlag(
"PANZOOM_PINCH_DEBUG_MESSAGES",
false,
FlagCategory.DebugMessages
)
);
}
register(...flags: ExperimentFlag[]) {
this.flags.push(...flags);
}
getFlag(flag: string) {
return this.flags.find((f) => f.id === flag);
}
enabled(flag: string) {
return this.getFlag(flag)?.enabled;
}
setEnabled(flagID: string, enabled: boolean) {
const flag = this.flags.find((f) => f.id === flagID);
if (!flag) throw new Error("Unknown flag " + flagID);
flag.enabled = enabled;
if (enabled) {
this.emit("enable", flagID);
} else {
this.emit("disable", flagID);
}
}
getAll() {
return [...this.flags].sort((a, b) => a.category - b.category);
}
}
/**
* Debug wrapper
*
* Goals:
* - toggle debug flags (similar to Discord experiments)
* - open blank debug tab with useragent and any flags
*/
class Debugcl extends EventEmitter<DebugEvents> {
readonly flags = new FlagManager();
_getRenderer: any;
constructor() {
super();
}
openDebug() {
const wind = window.open("about:blank", "_blank");
if (!wind) {
alert(
"Failed to open debug tab. Is your anti-popup too powerful? Or did this get triggered from not a trusted event"
);
return;
}
wind.document.write(`
<h1>debug menu</h1>
<pre>${JSON.stringify(
{
userAgent: navigator.userAgent,
flags: this.flags
.getAll()
.filter((f) => f.enabled)
.map((f) => f.id),
},
null,
2
)}</pre>
`);
wind.document.close();
}
openDebugTools() {
this.emit("openTools");
}
/**
* Create debug marker
*
* Useful on touchscreen devices
*
* @param type
* @param args
* @returns
*/
debug<T extends keyof DebugArgs>(type: T, ...args: DebugArgs[T]) {
switch (type) {
case "point": {
const [x, y, id] = args;
if (document.getElementById("debug-" + id)) {
document.getElementById("debug-" + id)!.style.top = y + "px";
document.getElementById("debug-" + id)!.style.left = x + "px";
return;
}
let el = document.createElement("div");
if (id) el.id = "debug-" + id;
el.classList.add("debug-point");
el.style.setProperty("top", y + "px");
el.style.setProperty("left", x + "px");
document.body.appendChild(el);
break;
}
case "text": {
const [str] = args;
// create debug box in canvas-meta if it doesn't exist
if (!document.getElementById("canvas-meta-debug")) {
let debugBox = document.createElement("div");
debugBox.id = "canvas-meta-debug";
debugBox.style.whiteSpace = "pre";
debugBox.style.unicodeBidi = "embed";
document.getElementById("canvas-meta")!.prepend(debugBox);
}
document.getElementById("canvas-meta-debug")!.innerText =
typeof str === "string" ? str : JSON.stringify(str, null, 2);
break;
}
}
}
getRenderer() {
return this._getRenderer();
}
}
const Debug = new Debugcl();
// @ts-ignore
window.Debug = Debug;
export { Debug };