replace client with client-next

This commit is contained in:
Grant 2024-02-15 19:44:12 -07:00
parent b1b4fdffb4
commit d29419bcf7
33 changed files with 328 additions and 1238 deletions

View File

@ -1,52 +0,0 @@
{
"name": "client",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "vite build",
"dev": "vite serve",
"preview": "vite preview",
"lint": "eslint ."
},
"type": "module",
"keywords": [],
"author": "",
"license": "ISC",
"eslintConfig": {
"extends": "react-app"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@nextui-org/react": "^2.2.9",
"@sc07-canvas/lib": "^1.0.0",
"eventemitter3": "^5.0.1",
"framer-motion": "^11.0.5",
"lodash.throttle": "^4.1.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-zoom-pan-pinch": "^3.4.1",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@tsconfig/vite-react": "^3.0.0",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.35",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"vite": "^5.1.1",
"vite-plugin-simple-html": "^0.1.2"
}
}

View File

@ -1,272 +0,0 @@
import EventEmitter from "eventemitter3";
import { ClientConfig, IPalleteContext, IPosition, Pixel } from "../types";
import Network from "./network";
import {
ClickEvent,
HoverEvent,
PanZoom,
} from "@sc07-canvas/lib/src/renderer/PanZoom";
interface CanvasEvents {
/**
* Cursor canvas position
* (-1, -1) is not on canvas
* @param position Canvas position
* @returns
*/
cursorPos: (position: IPosition) => void;
}
export class Canvas extends EventEmitter<CanvasEvents> {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig;
private canvas: HTMLCanvasElement;
private PanZoom: PanZoom;
private ctx: CanvasRenderingContext2D;
private cursor = { x: -1, y: -1 };
private pixels: {
[x_y: string]: { color: number; type: "full" | "pending" };
} = {};
private lastPlace: number | undefined;
constructor(
config: ClientConfig,
canvas: HTMLCanvasElement,
PanZoom: PanZoom
) {
super();
Canvas.instance = this;
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.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
this.draw();
}
destroy() {
this._destroy = true;
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
Network.off("canvas", this.handleBatch.bind(this));
}
handleMouseDown(e: ClickEvent) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
}
handleMouseMove(e: HoverEvent) {
const canvasRect = this.canvas.getBoundingClientRect();
if (
canvasRect.left <= e.clientX &&
canvasRect.right >= e.clientX &&
canvasRect.top <= e.clientY &&
canvasRect.bottom >= e.clientY
) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
} else {
this.cursor.x = -1;
this.cursor.y = -1;
}
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);
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
});
}
handlePixel({ x, y, color }: Pixel) {
this.pixels[x + "_" + y] = {
color,
type: "full",
};
}
palleteCtx: IPalleteContext = {};
Pallete = {
getColor: (colorId: number) => {
return this.config.pallete.colors.find((c) => c.id === colorId);
},
getSelectedColor: () => {
if (!this.palleteCtx.color) return undefined;
return this.Pallete.getColor(this.palleteCtx.color);
},
getColorFromHex: (hex: string) => {
return this.config.pallete.colors.find((c) => c.hex === hex);
},
};
updatePallete(pallete: IPalleteContext) {
this.palleteCtx = pallete;
}
place(x: number, y: number) {
if (!this.Pallete.getSelectedColor()) return;
if (this.lastPlace) {
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
console.log("cannot place; cooldown");
return;
}
}
this.lastPlace = Date.now();
Network.socket
.emitWithAck("place", {
x,
y,
color: this.Pallete.getSelectedColor()!.id,
})
.then((ack) => {
if (ack.success) {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("error: " + ack.error);
}
});
}
panZoomTransformToCanvas() {
const { x, y, scale: zoom } = this.PanZoom.transform;
const rect = this.canvas.getBoundingClientRect();
let canvasX = 0;
let canvasY = 0;
if (this.PanZoom.flags.useZoom) {
// css zoom doesn't change the bounding client rect
// therefore dividing by zoom doesn't return the correct output
canvasX = this.canvas.width - (x + rect.width / 2);
canvasY = this.canvas.height - (y + rect.height / 2);
} else {
canvasX = this.canvas.width / 2 - (x + rect.width / zoom);
canvasY = this.canvas.height / 2 - (y + rect.height / zoom);
canvasX += this.canvas.width;
canvasY += this.canvas.height;
}
canvasX >>= 0;
canvasY >>= 0;
return { canvasX, canvasY };
}
debug(x: number, y: number, id?: string) {
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);
}
screenToPos(x: number, y: number) {
// the rendered dimentions in the browser
const rect = this.canvas.getBoundingClientRect();
let output = {
x: 0,
y: 0,
};
if (this.PanZoom.flags.useZoom) {
const scale = this.PanZoom.transform.scale;
output.x = x / scale - rect.left;
output.y = y / scale - rect.top;
} else {
// get the ratio
const scale = [
this.canvas.width / rect.width,
this.canvas.height / rect.height,
];
output.x = (x - rect.left) * scale[0];
output.y = (y - rect.top) * scale[1];
}
// floor it, we're getting canvas coords, which can't have decimals
output.x >>= 0;
output.y >>= 0;
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());
}
}

View File

@ -1,146 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html,
body {
overscroll-behavior: contain;
touch-action: none;
background-color: #ddd !important;
}
header#main-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: row;
box-sizing: border-box;
z-index: 9999;
touch-action: none;
pointer-events: none;
.spacer {
flex-grow: 1;
}
.box {
padding: 10px;
touch-action: initial;
pointer-events: initial;
}
}
.user-card {
background-color: #444;
color: #fff;
border-radius: 10px;
padding: 5px 10px;
display: flex;
flex-direction: row;
gap: 10px;
&--overview {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1;
span:first-of-type {
font-size: 130%;
margin-bottom: 5px;
}
}
img {
width: 64px;
height: 64px;
background-color: #aaa;
border-radius: 50%;
}
}
#cursor {
position: fixed;
top: 20px;
left: 10px;
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
pointer-events: none;
will-change: transform;
z-index: 2;
}
#canvas-meta {
position: absolute;
top: -10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
flex-direction: column;
.canvas-meta--cursor-pos {
font-style: italic;
}
}
main {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
canvas {
display: block;
box-sizing: border-box;
}
}
.pixelate {
image-rendering: optimizeSpeed;
-ms-interpolation-mode: nearest-neighbor;
image-rendering: -webkit-optimize-contrast;
image-rendering: -webkit-crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.btn-link {
background-color: transparent;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font-size: inherit;
text-decoration: underline;
&:active {
opacity: 0.5;
}
}
@import "./components/Pallete.scss";
@import "./board.scss";

View File

@ -1,3 +0,0 @@
{
"extends": "@tsconfig/vite-react/tsconfig.json",
}

View File

@ -1,26 +0,0 @@
var gulp = require("gulp");
var ts = require("gulp-typescript");
const webpack = require("webpack-stream");
const sass = require("gulp-sass")(require("sass"));
gulp.task("css", function () {
return gulp
.src("src/**/*.scss")
.pipe(sass().on("error", sass.logError))
.pipe(gulp.dest("./public/css"));
});
gulp.task("js", function () {
return gulp
.src("src/**/*.ts")
.pipe(webpack(require("./webpack.config.js")))
.pipe(gulp.dest("public"));
});
gulp.task(
"watch",
gulp.series("js", "css", function () {
gulp.watch("src/**/*.ts", gulp.series("js"));
gulp.watch("src/**/*.scss", gulp.series("css"));
})
);

View File

@ -1,26 +1,52 @@
{
"name": "@sc07-canvas/client",
"name": "client",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "gulp watch"
"build": "vite build",
"dev": "vite serve",
"preview": "vite preview",
"lint": "eslint ."
},
"type": "module",
"keywords": [],
"author": "",
"license": "ISC",
"eslintConfig": {
"extends": "react-app"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@nextui-org/react": "^2.2.9",
"@sc07-canvas/lib": "^1.0.0",
"gulp": "^4.0.2",
"gulp-cli": "^2.3.0",
"gulp-typescript": "^2.12.2",
"socket.io-client": "^4.7.2",
"ts-loader": "^9.4.4"
"eventemitter3": "^5.0.1",
"framer-motion": "^11.0.5",
"lodash.throttle": "^4.1.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-zoom-pan-pinch": "^3.4.1",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@babel/cli": "^7.22.10",
"@babel/core": "^7.22.10",
"@babel/preset-env": "^7.22.10",
"babel-loader": "^9.1.3",
"gulp-sass": "^5.1.0",
"sass": "^1.65.1",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-stream": "^7.0.0"
"@tsconfig/vite-react": "^3.0.0",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.35",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"vite": "^5.1.1",
"vite-plugin-simple-html": "^0.1.2"
}
}

View File

@ -1,64 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>Document</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<header>
<div></div>
<div class="spacer"></div>
<div>
<div class="user-card">
<div class="user-card--overview">
<div class="user-name"></div>
<div class="user-instance"></div>
</div>
<img src="#" alt="User Avatar" class="user-avatar" />
</div>
</div>
</header>
<main>
<div id="board-zoom">
<div id="board-move" style="width: 100px; height: 100px">
<canvas
id="board"
width="1000"
height="1000"
class="pixelate"
></canvas>
</div>
</div>
</main>
<div id="cursor" style="display: none"></div>
<div id="pallete">
<div id="canvas-meta">
<!-- canvas is in charge of the metabox -->
<span>
Pixels:
<span class="canvas-meta--pixels"></span>
</span>
<span>
Users Online:
<span class="canvas-meta--online"></span>
</span>
<button id="test">test button</button>
</div>
<div class="pallete-colors"></div>
<div class="pallete-user-overlay">
you are not logged in
<a href="/api/login" class="user-login">login</a>
</div>
</div>
<script src="bundle.js"></script>
</body>
</html>

View File

@ -1,11 +0,0 @@
import "./lib/net";
import Pallete from "./lib/pallete";
import Canvas from "./lib/canvas";
window.addEventListener("mousemove", (e) => {
const cursor = document.getElementById("cursor");
cursor!.style.transform = `translate(${e.pageX}px, ${e.pageY}px)`;
});
Canvas.setup();
Canvas.draw();

View File

@ -1,32 +0,0 @@
import { SUserPacket, AuthSession } from "@sc07-canvas/lib/src/net";
const $puseroverlay = document.querySelector(
"#pallete .pallete-user-overlay"
)! as HTMLDivElement;
const $usercard = document.querySelector(".user-card")! as HTMLDivElement;
const $usercard_name = $usercard.querySelector(".user-name")! as HTMLDivElement;
const $usercard_instance = $usercard.querySelector(
".user-instance"
)! as HTMLDivElement;
const $usercard_avatar = $usercard.querySelector(
"img.user-avatar"
)! as HTMLImageElement;
class Auth {
private user: AuthSession | undefined;
login() {
window.location.href = "/api/login";
}
handleAuth(data: SUserPacket) {
this.user = data.user;
$puseroverlay.style.display = "none";
$usercard_name.innerText = data.user.user.username;
$usercard_instance.innerHTML = data.user.service.instance.hostname;
$usercard_avatar.setAttribute("src", data.user.user.profile);
}
}
export default new Auth();

View File

@ -1,407 +1,99 @@
import pallete from "./pallete";
import Network from "./net";
import EventEmitter from "eventemitter3";
import { ClientConfig, IPalleteContext, IPosition, Pixel } from "../types";
import Network from "./network";
import {
CPixelPacket,
SCanvasPacket,
SPixelPacket,
} from "@sc07-canvas/lib/src/net";
const $moveContainer = document.querySelector("main")!;
const $canvas = document.getElementById("board")! as HTMLCanvasElement;
const $zoom = document.getElementById("board-zoom")!;
const $move = document.getElementById("board-move")!;
const $container = document.querySelector("main")!;
const $metabox = document.getElementById("canvas-meta")! as HTMLDivElement;
const $metabox_pixels = $metabox.querySelector(
".canvas-meta--pixels"
) as HTMLSpanElement;
const $metabox_online = $metabox.querySelector(
".canvas-meta--online"
) as HTMLSpanElement;
const $testbtn = document.getElementById("test")! as HTMLButtonElement;
export type CanvasConfig = {
size: [number, number];
zoom: number;
};
const ZOOM_SPEED = 0.1;
class Canvas {
private config: CanvasConfig = { size: [0, 0], zoom: 1 };
ClickEvent,
HoverEvent,
PanZoom,
} from "@sc07-canvas/lib/src/renderer/PanZoom";
interface CanvasEvents {
/**
* Last pixel place date
*
* TODO: Support pixel stacking
* Cursor canvas position
* (-1, -1) is not on canvas
* @param position Canvas position
* @returns
*/
private lastPlace: Date | undefined;
private zoom = 1;
private move = { x: 0, y: 0 };
cursorPos: (position: IPosition) => void;
}
export class Canvas extends EventEmitter<CanvasEvents> {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig;
private canvas: HTMLCanvasElement;
private PanZoom: PanZoom;
private ctx: CanvasRenderingContext2D;
private cursor = { x: -1, y: -1 };
private ctx: CanvasRenderingContext2D | null = null;
// private pixels: {
// x: number;
// y: number;
// color: number;
// type: "full" | "pending";
// }[] = [];
private pixels: {
[x_y: string]: { color: number; type: "full" | "pending" };
} = {};
private lastPlace: number | undefined;
private mouseDown = false;
private dragOrigin = { x: 0, y: 0 };
private downTime: number | undefined;
constructor(
config: ClientConfig,
canvas: HTMLCanvasElement,
PanZoom: PanZoom
) {
super();
Canvas.instance = this;
load(config: CanvasConfig) {
this.config = config;
$canvas.setAttribute("width", config.size[0] + "");
$canvas.setAttribute("height", config.size[1] + "");
this.setZoom(config.zoom);
this.canvas = canvas;
this.PanZoom = PanZoom;
this.ctx = canvas.getContext("2d")!;
canvas.width = config.canvas.size[0];
canvas.height = config.canvas.size[1];
this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
this.draw();
}
setZoom(newzoom: number, mouse?: { x: number; y: number }) {
if (newzoom < 0.5) newzoom = 0.5;
const oldzoom = this.zoom;
this.zoom = newzoom;
destroy() {
this._destroy = true;
$zoom.style.transform = `scale(${newzoom})`;
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
if (mouse) {
const dx = mouse.x - $container.clientWidth / 2;
const dy = mouse.y - $container.clientHeight / 2;
this.move.x -= dx / oldzoom;
this.move.x += dx / newzoom;
this.move.y -= dy / oldzoom;
this.move.y += dy / newzoom;
$move.style.transform = `translate(${this.move.x}px, ${this.move.y}px)`;
}
Network.off("canvas", this.handleBatch.bind(this));
}
setup() {
this.ctx = $canvas.getContext("2d");
handleMouseDown(e: ClickEvent) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
}
let pinching = false;
let pinchInit = 0;
$testbtn.addEventListener("click", (e) => {
e.preventDefault();
$canvas.classList.toggle("pixelate");
});
window.addEventListener("wheel", (e) => {
const oldzoom = this.zoom;
let newzoom = (this.zoom += ZOOM_SPEED * (e.deltaY > 0 ? -1 : 1));
this.setZoom(newzoom, {
x: e.clientX,
y: e.clientY,
});
});
const handleMouseStart = (e: { pageX: number; pageY: number }) => {
this.mouseDown = true;
this.dragOrigin = { x: e.pageX, y: e.pageY };
this.downTime = Date.now();
};
$moveContainer.addEventListener(
"touchstart",
(e) => {
if (e.changedTouches.length === 2) {
pinching = true;
pinchInit = Math.hypot(
e.touches[0].pageX - e.touches[1].pageX,
e.touches[0].pageY - e.touches[1].pageY
);
} else {
handleMouseStart(e.touches[0]);
}
$testbtn.innerText = e.changedTouches.length + "";
},
{ passive: false }
);
$moveContainer.addEventListener("mousedown", handleMouseStart);
const update = (x: number, y: number, update: boolean) => {
const deltaX = (x - this.dragOrigin.x) / this.zoom;
const deltaY = (y - this.dragOrigin.y) / this.zoom;
const newX = this.move.x + deltaX;
const newY = this.move.y + deltaY;
if (update) {
this.move.x += deltaX;
this.move.y += deltaY;
}
$move.style.transform = `translate(${newX}px, ${newY}px)`;
};
const handleMouseEnd = (e: {
clientX: number;
clientY: number;
pageX: number;
pageY: number;
}) => {
if (!this.mouseDown) return;
this.mouseDown = false;
const downDelta = Date.now() - this.downTime!;
const delta = [
Math.abs(this.dragOrigin.x - e.clientX),
Math.abs(this.dragOrigin.y - e.clientY),
];
if (downDelta < 500) {
// mouse was down for less than 500ms
if (delta[0] < 5 && delta[1] < 5) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
}
}
update(e.pageX, e.pageY, true);
};
window.addEventListener("touchend", (e) => {
if (pinching) {
console.log("pinching end");
pinching = false;
} else {
handleMouseEnd(e.changedTouches[0]);
}
});
window.addEventListener("mouseup", (e) => {
if (pinching) {
pinching = false;
} else {
handleMouseEnd(e);
}
});
const handleMouseMove = (e: {
pageX: number;
pageY: number;
clientX: number;
clientY: number;
}) => {
if (this.mouseDown) {
update(e.pageX, e.pageY, false);
} else {
const canvasRect = $canvas.getBoundingClientRect();
if (
canvasRect.left <= e.pageX &&
canvasRect.right >= e.pageX &&
canvasRect.top <= e.pageY &&
canvasRect.bottom >= e.pageY
) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
} else {
this.cursor.x = -1;
this.cursor.y = -1;
}
}
};
window.addEventListener(
"touchmove",
(e) => {
if (pinching) {
let initDiff = Math.hypot(
e.touches[0].pageX - e.touches[1].pageX,
e.touches[0].pageY - e.touches[1].pageY
);
let diff = (pinchInit - initDiff) / (10 / this.zoom);
let newzoom = (this.zoom += ZOOM_SPEED * -diff);
this.setZoom(newzoom, {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
pinchInit = initDiff;
} else {
handleMouseMove(e.touches[0]);
}
},
{ passive: false }
);
window.addEventListener("mousemove", handleMouseMove);
$canvas.addEventListener("touchmove", (e) => {
if (!pinching) {
const [x, y] = this.screenToPos(
e.touches[0].clientX,
e.touches[0].clientY
);
this.cursor.x = x;
this.cursor.y = y;
}
});
$canvas.addEventListener("mousemove", (e) => {
handleMouseMove(e: HoverEvent) {
const canvasRect = this.canvas.getBoundingClientRect();
if (
canvasRect.left <= e.clientX &&
canvasRect.right >= e.clientX &&
canvasRect.top <= e.clientY &&
canvasRect.bottom >= e.clientY
) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
// window.requestAnimationFrame(() => this.draw());
});
}
screenToPos(x: number, y: number) {
const rect = $canvas.getBoundingClientRect();
const scale = [$canvas.width / rect.width, $canvas.height / rect.height];
return [x - rect.left, y - rect.top]
.map((v, i) => v * scale[i])
.map((v) => v >> 0);
}
draw() {
if (!this.ctx) {
window.requestAnimationFrame(() => this.draw());
return;
}
/* @ts-ignore */
this.ctx.mozImageSmoothingEnabled =
/* @ts-ignore */
this.ctx.webkitImageSmoothingEnabled =
/* @ts-ignore */
this.ctx.msImageSmoothingEnabled =
this.ctx.imageSmoothingEnabled =
false;
this.ctx.canvas.width = window.innerWidth;
this.ctx.canvas.height = window.innerHeight;
const bezier = (n: number) => n * n * (3 - 2 * n);
this.ctx.globalAlpha = 1;
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(0, 0, this.config.size[0], this.config.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
? "#" + pallete.getPalleteColor(pixel.color)!.hex
: "transparent";
this.ctx.fillRect(x, y, 1, 1);
}
if (pallete.getColor() && this.cursor.x > -1 && this.cursor.y > -1) {
let t = ((Date.now() / 100) % 10) / 10;
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
this.ctx.fillStyle = "#" + pallete.getColor()!.hex;
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
}
this.renderMetabox();
window.requestAnimationFrame(() => this.draw());
}
/**
* update canvas metabox with latest information
*
* executed by #draw()
*/
renderMetabox() {
if (this.lastPlace) {
let cooldown =
this.lastPlace.getTime() +
pallete.getPixelCooldown() -
new Date().getTime();
$metabox_pixels.innerText = cooldown > 0 ? cooldown / 1000 + "s" : "none";
} else {
$metabox_pixels.innerText = "no cooldown";
this.cursor.x = -1;
this.cursor.y = -1;
}
$metabox_online.innerText = Network.getOnline() + "";
this.emit("cursorPos", this.cursor);
}
place(x: number, y: number) {
if (!pallete.getColor()) return;
if (this.lastPlace) {
if (
this.lastPlace.getTime() + pallete.getPixelCooldown() >
new Date().getTime()
) {
console.log("cannot place, cooldown in place");
return;
}
}
this.lastPlace = new Date();
const color = pallete.getColor()!;
// this.pixels.push({
// x,
// y,
// color: color.id,
// type: "pending",
// });
this.pixels[x + "_" + y] = {
color: color.id,
type: "pending",
};
Network.sendAck<CPixelPacket, SPixelPacket>("place", {
x,
y,
color: color.id,
}).then((ack) => {
// remove pending pixel at coord
// can remove regardless of success or failure, as success places pixel at coord from server
// this.pixels.splice(
// this.pixels.indexOf(
// this.pixels.find(
// (pxl) =>
// pxl.x === x &&
// pxl.y === y &&
// pxl.color === color.id &&
// pxl.type === "pending"
// )!
// ),
// 1
// );
if (ack.success) {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("Error: " + ack.error);
}
});
}
handlePixel({ x, y, color, ...rest }: SPixelPacket) {
// this.pixels.push({
// x,
// y,
// color,
// type: "full",
// });
this.pixels[x + "_" + y] = {
color,
type: "full",
};
}
handleBatch(batch: SCanvasPacket) {
batch.pixels.forEach((hex, index) => {
const x = index / this.config.size[0];
const y = index % this.config.size[0];
const color = pallete.getPalleteFromHex(hex);
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);
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
@ -409,6 +101,172 @@ class Canvas {
};
});
}
}
export default new Canvas();
handlePixel({ x, y, color }: Pixel) {
this.pixels[x + "_" + y] = {
color,
type: "full",
};
}
palleteCtx: IPalleteContext = {};
Pallete = {
getColor: (colorId: number) => {
return this.config.pallete.colors.find((c) => c.id === colorId);
},
getSelectedColor: () => {
if (!this.palleteCtx.color) return undefined;
return this.Pallete.getColor(this.palleteCtx.color);
},
getColorFromHex: (hex: string) => {
return this.config.pallete.colors.find((c) => c.hex === hex);
},
};
updatePallete(pallete: IPalleteContext) {
this.palleteCtx = pallete;
}
place(x: number, y: number) {
if (!this.Pallete.getSelectedColor()) return;
if (this.lastPlace) {
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
console.log("cannot place; cooldown");
return;
}
}
this.lastPlace = Date.now();
Network.socket
.emitWithAck("place", {
x,
y,
color: this.Pallete.getSelectedColor()!.id,
})
.then((ack) => {
if (ack.success) {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("error: " + ack.error);
}
});
}
panZoomTransformToCanvas() {
const { x, y, scale: zoom } = this.PanZoom.transform;
const rect = this.canvas.getBoundingClientRect();
let canvasX = 0;
let canvasY = 0;
if (this.PanZoom.flags.useZoom) {
// css zoom doesn't change the bounding client rect
// therefore dividing by zoom doesn't return the correct output
canvasX = this.canvas.width - (x + rect.width / 2);
canvasY = this.canvas.height - (y + rect.height / 2);
} else {
canvasX = this.canvas.width / 2 - (x + rect.width / zoom);
canvasY = this.canvas.height / 2 - (y + rect.height / zoom);
canvasX += this.canvas.width;
canvasY += this.canvas.height;
}
canvasX >>= 0;
canvasY >>= 0;
return { canvasX, canvasY };
}
debug(x: number, y: number, id?: string) {
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);
}
screenToPos(x: number, y: number) {
// the rendered dimentions in the browser
const rect = this.canvas.getBoundingClientRect();
let output = {
x: 0,
y: 0,
};
if (this.PanZoom.flags.useZoom) {
const scale = this.PanZoom.transform.scale;
output.x = x / scale - rect.left;
output.y = y / scale - rect.top;
} else {
// get the ratio
const scale = [
this.canvas.width / rect.width,
this.canvas.height / rect.height,
];
output.x = (x - rect.left) * scale[0];
output.y = (y - rect.top) * scale[1];
}
// floor it, we're getting canvas coords, which can't have decimals
output.x >>= 0;
output.y >>= 0;
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());
}
}

View File

@ -1,74 +0,0 @@
import io from "socket.io-client";
import Pallete from "./pallete";
import Canvas from "./canvas";
import {
CPixelPacket,
ClientPacket,
PacketAck,
SCanvasPacket,
SPixelPacket,
SUserPacket,
ServerPacket,
} from "@sc07-canvas/lib/src/net";
import Auth from "./auth";
class Network {
private socket;
private online_count = 0;
constructor() {
this.socket = io();
this.socket.on("config", (config) => {
Pallete.load(config.pallete);
Canvas.load(config.canvas);
});
this.socket.on("pixel", (data: SPixelPacket) => {
Canvas.handlePixel(data);
});
this.socket.on("user", (data: SUserPacket) => {
Auth.handleAuth(data);
});
this.socket.on("canvas", (data: SCanvasPacket) => {
Canvas.handleBatch(data);
});
this.socket.on("online", (data: { count: number }) => {
this.online_count = data.count;
});
}
send<T extends ClientPacket>(
type: T["type"],
data: Omit<T, "type" | "_direction">
) {
// @ts-ignore
data._direction = "client->server";
this.socket.emit(type, data);
}
sendAck<O extends ClientPacket, I extends ServerPacket>(
type: O["type"],
data: Omit<O, "type" | "_direction">
) {
return new Promise<PacketAck<I>>((res) => {
this.socket.emit(type, data, (data: PacketAck<I>) => {
res(data);
});
});
}
/**
* Get online user count
* @returns online users count
*/
getOnline() {
return this.online_count;
}
}
export default new Network();

View File

@ -1,80 +0,0 @@
import { SUserPacket } from "@sc07-canvas/lib/src/net";
export type PalleteColor = {
id: number;
hex: string;
name: string;
};
const $pallete = document.getElementById("pallete")!;
const $pcolors = $pallete.querySelector(".pallete-colors")!;
const $puseroverlay = $pallete.querySelector(
".pallete-user-overlay"
)! as HTMLDivElement;
const $cursor = document.getElementById("cursor")!;
class Pallete {
private pallete: PalleteColor[] = [];
private active: PalleteColor | undefined;
private pixel_cooldown = 0;
load({
colors,
pixel_cooldown,
}: {
colors: PalleteColor[];
pixel_cooldown: number;
}) {
$pcolors.innerHTML = "";
this.pallete = colors;
this.pixel_cooldown = pixel_cooldown;
colors.forEach((color) => {
const $color = document.createElement("a");
$color.href = "#";
$color.classList.add("pallete-color");
$color.style.backgroundColor = "#" + color.hex;
$color.addEventListener("click", () => {
this.pick(color);
});
$pcolors.append($color);
});
this.active = undefined;
}
pick(color?: PalleteColor) {
this.active = color;
$cursor.style.display = color ? "block" : "none";
if (color) $cursor.style.backgroundColor = "#" + color.hex;
}
getColor() {
return this.active;
}
getPallete() {
return this.pallete;
}
getPalleteColor(id: number) {
return this.pallete.find((p) => p.id === id);
}
getPalleteFromHex(hex: string) {
return this.pallete.find((p) => p.hex === hex);
}
/**
* Get pixel cooldown
* @returns Pixel cooldown in ms
*/
getPixelCooldown() {
return this.pixel_cooldown;
}
}
export default new Pallete();

View File

@ -1,14 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html,
body {
overscroll-behavior: contain;
touch-action: none;
background-color: #ddd !important;
}
body {
background-color: #ddd;
}
header {
header#main-header {
position: fixed;
top: 0;
left: 0;
@ -16,13 +22,20 @@ header {
display: flex;
flex-direction: row;
padding: 10px;
box-sizing: border-box;
z-index: 9999;
touch-action: none;
pointer-events: none;
.spacer {
flex-grow: 1;
}
.box {
padding: 10px;
touch-action: initial;
pointer-events: initial;
}
}
.user-card {
@ -70,59 +83,20 @@ header {
z-index: 2;
}
#pallete {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
#canvas-meta {
position: absolute;
top: -10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
gap: 10px;
padding: 10px;
flex-direction: column;
background-color: #fff;
.pallete-colors {
display: flex;
width: 100%;
justify-content: center;
gap: 10px;
padding: 10px;
}
.pallete-color {
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
}
.pallete-user-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
#canvas-meta {
position: absolute;
top: -10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
flex-direction: column;
.canvas-meta--cursor-pos {
font-style: italic;
}
}
@ -153,3 +127,20 @@ main {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.btn-link {
background-color: transparent;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font-size: inherit;
text-decoration: underline;
&:active {
opacity: 0.5;
}
}
@import "./components/Pallete.scss";
@import "./board.scss";

View File

@ -1,6 +1,3 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"sourceMap": true
}
"extends": "@tsconfig/vite-react/tsconfig.json",
}

View File

@ -1,22 +0,0 @@
const path = require("path");
module.exports = {
entry: "./src/index.ts",
devtool: "inline-source-map",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "public"),
},
};