440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
import { convertHex } from '../common/convert';
|
|
import { clamp, debounce } from '../common/helper';
|
|
import { CanvasRecord, Placement } from '../common/types/canvas';
|
|
import { IcyNetUser } from '../common/types/user';
|
|
import { Picker } from './picker';
|
|
import { $ } from './utils/dom';
|
|
|
|
export class ViewCanvas {
|
|
public picker = new Picker();
|
|
private _user?: IcyNetUser;
|
|
|
|
private _placeFn?: (placement: Placement) => void;
|
|
private _getPlacerFn?: (x: number, y: number) => Promise<CanvasRecord>;
|
|
private _canvas = $('<canvas class="canvas">') as HTMLCanvasElement;
|
|
private _ctx = this._canvas.getContext('2d');
|
|
private _wrapper = $('<div class="canvas__wrapper">');
|
|
private _container = $('<div class="canvas__container">');
|
|
private _cursor = $('<div class="canvas__cursor">');
|
|
private _coods = $('<div class="canvas__coordinates">');
|
|
private _userInfo = $('<a class="canvas__user">');
|
|
private _size = 1000;
|
|
|
|
private _viewWidth = 0;
|
|
private _viewHeight = 0;
|
|
|
|
private _posx = 0;
|
|
private _posy = 0;
|
|
private _zoom = 1;
|
|
|
|
private _mousex = 0;
|
|
private _mousey = 0;
|
|
|
|
private _relmousex = 0;
|
|
private _relmousey = 0;
|
|
|
|
private _cursorx = 0;
|
|
private _cursory = 0;
|
|
private _relcursorx = 0;
|
|
private _relcursory = 0;
|
|
private _screencursorx = 0;
|
|
private _screencursory = 0;
|
|
|
|
private _dragging = false;
|
|
private _pinching = false;
|
|
private _previousPinchLength = 0;
|
|
|
|
private _placerTag: HTMLElement | null = null;
|
|
private _placerRequestTime: number = 0;
|
|
|
|
constructor() {}
|
|
|
|
public moveCanvas(): void {
|
|
this._canvas.style.transform = `scale(${this._zoom})`;
|
|
this._container.style.transform = `translate(${this._posx}px, ${this._posy}px)`;
|
|
}
|
|
|
|
public center(): void {
|
|
const offsetWidth = this._viewWidth - this._size;
|
|
const offsetHeight = this._viewHeight - this._size;
|
|
|
|
this._posx = offsetWidth / 2;
|
|
this._posy = offsetHeight / 2;
|
|
|
|
this.moveCanvas();
|
|
this.moveCursor();
|
|
}
|
|
|
|
public moveCursor(): void {
|
|
this._resetPlacerTag();
|
|
|
|
// Apparent size of the canvas after scaling it
|
|
const realSize = this._zoom * this._size;
|
|
// The difference between the real canvas size and apparent size
|
|
const screenX = this._posx;
|
|
const screenY = this._posy;
|
|
|
|
// Position of the on-screen cursor, snapped
|
|
// Relative to top left of screen
|
|
this._cursorx = Math.floor(
|
|
clamp(this._viewWidth / 2, screenX, screenX + realSize),
|
|
);
|
|
this._cursory = Math.floor(
|
|
clamp(this._viewHeight / 2, screenY, screenY + realSize),
|
|
);
|
|
|
|
// Position of the cursor on the canvas
|
|
this._relcursorx = clamp(
|
|
Math.floor((this._cursorx - screenX) / this._zoom),
|
|
0,
|
|
this._size - 1,
|
|
);
|
|
this._relcursory = clamp(
|
|
Math.floor((this._cursory - screenY) / this._zoom),
|
|
0,
|
|
this._size - 1,
|
|
);
|
|
|
|
this._screencursorx = this._relcursorx * this._zoom + screenX;
|
|
this._screencursory = this._relcursory * this._zoom + screenY;
|
|
|
|
this._cursor.style.transform = `translate(${this._screencursorx}px, ${this._screencursory}px)`;
|
|
this._cursor.style.width = `${this._zoom}px`;
|
|
this._cursor.style.height = `${this._zoom}px`;
|
|
|
|
this._coods.innerText = `(${this._relcursorx}, ${
|
|
this._relcursory
|
|
}) ${this._zoom.toFixed(2)}x`;
|
|
|
|
this._updateURL();
|
|
|
|
if (this._zoom > 20) {
|
|
this._getPlacerAt(this._relcursorx, this._relcursory);
|
|
}
|
|
}
|
|
|
|
public initialize(): void {
|
|
this.picker.initialize();
|
|
|
|
this._userInfo.innerText = 'Login';
|
|
this._userInfo.setAttribute('href', '/login');
|
|
|
|
this._wrapper.append(this._coods);
|
|
this._container.append(this._canvas);
|
|
this._wrapper.append(this._cursor);
|
|
this._wrapper.append(this._container);
|
|
this._wrapper.append(this._userInfo);
|
|
this._wrapper.append(this.picker.element);
|
|
document.body.append(this._wrapper);
|
|
|
|
const dragEvent = (x: number, y: number) => {
|
|
const currentX = this._mousex;
|
|
const currentY = this._mousey;
|
|
|
|
this._mousex = x;
|
|
this._mousey = y;
|
|
|
|
const offsetX = currentX - this._mousex;
|
|
const offsetY = currentY - this._mousey;
|
|
|
|
if (this._dragging) {
|
|
const realSize = this._zoom * this._size;
|
|
this._posx = clamp(
|
|
this._posx - offsetX,
|
|
this._cursorx - realSize,
|
|
this._cursorx,
|
|
);
|
|
this._posy = clamp(
|
|
this._posy - offsetY,
|
|
this._cursory - realSize,
|
|
this._cursory,
|
|
);
|
|
|
|
this.moveCanvas();
|
|
this.moveCursor();
|
|
}
|
|
};
|
|
|
|
this._wrapper.addEventListener('mousemove', (ev: MouseEvent) =>
|
|
dragEvent(ev.clientX, ev.clientY),
|
|
);
|
|
|
|
this._wrapper.addEventListener('mousedown', (ev: MouseEvent) => {
|
|
this._mousex = ev.clientX;
|
|
this._mousey = ev.clientY;
|
|
this._dragging = true;
|
|
});
|
|
|
|
this._wrapper.addEventListener('mouseup', (ev: MouseEvent) => {
|
|
this._dragging = false;
|
|
});
|
|
|
|
this._wrapper.addEventListener('touchstart', (ev: TouchEvent) => {
|
|
const touch = ev.touches[0] || ev.changedTouches[0];
|
|
this._mousex = touch.pageX;
|
|
this._mousey = touch.pageY;
|
|
this._dragging = true;
|
|
|
|
if (ev.touches.length === 2) {
|
|
this._pinching = true;
|
|
}
|
|
});
|
|
|
|
this._wrapper.addEventListener('touchmove', (ev: TouchEvent) => {
|
|
ev.preventDefault();
|
|
|
|
if (ev.touches.length === 2 && this._pinching) {
|
|
const pinchLength = Math.hypot(
|
|
ev.touches[0].pageX - ev.touches[1].pageX,
|
|
ev.touches[0].pageY - ev.touches[1].pageY,
|
|
);
|
|
|
|
if (this._previousPinchLength) {
|
|
const delta = pinchLength / this._previousPinchLength;
|
|
const scaleX = (ev.touches[0].clientX - this._posx) / this._zoom;
|
|
const scaleY = (ev.touches[0].clientY - this._posy) / this._zoom;
|
|
|
|
delta > 0 ? (this._zoom *= delta) : (this._zoom /= delta);
|
|
this._zoom = clamp(this._zoom, 1, 100);
|
|
|
|
this._posx = ev.touches[0].clientX - scaleX * this._zoom;
|
|
this._posy = ev.touches[0].clientY - scaleY * this._zoom;
|
|
}
|
|
this._previousPinchLength = pinchLength;
|
|
}
|
|
|
|
dragEvent(ev.touches[0].clientX, ev.touches[0].clientY);
|
|
});
|
|
|
|
this._wrapper.addEventListener('touchend', (ev: TouchEvent) => {
|
|
this._pinching = false;
|
|
this._previousPinchLength = 0;
|
|
|
|
if (!ev.touches?.length) {
|
|
this._dragging = false;
|
|
}
|
|
});
|
|
|
|
this._wrapper.addEventListener('pointerleave', (ev: MouseEvent) => {
|
|
this._dragging = false;
|
|
});
|
|
|
|
this._wrapper.addEventListener('wheel', (ev: WheelEvent) => {
|
|
ev.preventDefault();
|
|
|
|
this._mousex = ev.clientX;
|
|
this._mousey = ev.clientY;
|
|
|
|
const scaleX = (ev.clientX - this._posx) / this._zoom;
|
|
const scaleY = (ev.clientY - this._posy) / this._zoom;
|
|
|
|
ev.deltaY < 0 ? (this._zoom *= 1.2) : (this._zoom /= 1.2);
|
|
this._zoom = clamp(this._zoom, 1, 100);
|
|
|
|
this._posx = ev.clientX - scaleX * this._zoom;
|
|
this._posy = ev.clientY - scaleY * this._zoom;
|
|
|
|
const realSize = this._zoom * this._size;
|
|
this._posx = clamp(this._posx, this._cursorx - realSize, this._cursorx);
|
|
this._posy = clamp(this._posy, this._cursory - realSize, this._cursory);
|
|
|
|
this.moveCursor();
|
|
this.moveCanvas();
|
|
});
|
|
|
|
this.picker.registerOnPlace((color) => {
|
|
if (this._placeFn) {
|
|
this._placeFn({
|
|
x: this._relcursorx,
|
|
y: this._relcursory,
|
|
c: color,
|
|
t: Date.now(),
|
|
});
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', () => this.setView());
|
|
window.addEventListener('keyup', (ev: KeyboardEvent) => {
|
|
const numeral = parseInt(ev.key, 10);
|
|
if (ev.key && !Number.isNaN(numeral)) {
|
|
this.picker.pickPalette(numeral === 0 ? 9 : numeral - 1);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', ' '].includes(
|
|
ev.key,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const pixelSize = this._zoom;
|
|
if (ev.key === 'ArrowLeft') {
|
|
this._posx += pixelSize;
|
|
} else if (ev.key === 'ArrowRight') {
|
|
this._posx -= pixelSize;
|
|
}
|
|
|
|
if (ev.key === 'ArrowUp') {
|
|
this._posy += pixelSize;
|
|
} else if (ev.key === 'ArrowDown') {
|
|
this._posy -= pixelSize;
|
|
}
|
|
|
|
if (ev.key === ' ') {
|
|
this.picker.place();
|
|
return;
|
|
}
|
|
|
|
this.moveCanvas();
|
|
this.moveCursor();
|
|
});
|
|
}
|
|
|
|
public setView() {
|
|
this._viewWidth = document.body.clientWidth;
|
|
this._viewHeight = document.body.clientHeight;
|
|
this._resetPositionOrCenter();
|
|
}
|
|
|
|
public fill(size: number, canvas: number[]) {
|
|
this._size = size;
|
|
this._canvas.width = this._size;
|
|
this._canvas.height = this._size;
|
|
|
|
const data = this._ctx!.getImageData(0, 0, this._size, this._size);
|
|
for (let row = 0; row < this._size; row++) {
|
|
for (let col = 0; col < this._size; col++) {
|
|
const index = col + row * this._size;
|
|
const pixel = canvas[index];
|
|
const { r, g, b } = convertHex(pixel);
|
|
data.data[4 * index] = r;
|
|
data.data[4 * index + 1] = g;
|
|
data.data[4 * index + 2] = b;
|
|
data.data[4 * index + 3] = 255;
|
|
}
|
|
}
|
|
|
|
this._ctx!.putImageData(data, 0, 0);
|
|
this.setView();
|
|
}
|
|
|
|
public setPixel(x: number, y: number, pixel: number) {
|
|
const { r, g, b } = convertHex(pixel);
|
|
this._ctx!.fillStyle = `rgb(${r},${g},${b})`;
|
|
this._ctx!.fillRect(x, y, 1, 1);
|
|
}
|
|
|
|
public registerOnPlace(fn: (placement: Placement) => void): void {
|
|
this._placeFn = fn;
|
|
}
|
|
|
|
public registerGetPlacer(
|
|
fn: (x: number, y: number) => Promise<CanvasRecord>,
|
|
): void {
|
|
this._getPlacerFn = fn;
|
|
}
|
|
|
|
public setUser(user: IcyNetUser): void {
|
|
this._user = user;
|
|
this.picker.setLoggedIn(user);
|
|
if (user) {
|
|
this._userInfo.innerText = user.username;
|
|
this._userInfo.classList.add('logged-in');
|
|
this._userInfo.setAttribute('href', '/logout');
|
|
}
|
|
}
|
|
|
|
private _updateURL = debounce(() => {
|
|
const urlelements = new URLSearchParams({
|
|
px: this._relcursorx.toString(),
|
|
py: this._relcursory.toString(),
|
|
z: this._zoom.toFixed(2),
|
|
});
|
|
|
|
window.history.replaceState(
|
|
null,
|
|
document.title,
|
|
`/?${urlelements.toString()}`,
|
|
);
|
|
}, 500);
|
|
|
|
private _getPlacerAtWaiter = debounce(
|
|
(x: number, y: number, order: number) => {
|
|
if (this._placerRequestTime === order) {
|
|
this._getPlacerFn(x, y).then((placer) => {
|
|
if (placer && this._placerRequestTime === order) {
|
|
this._showPlacerTag(placer);
|
|
this.picker.setPickColor(placer.color);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
1000,
|
|
);
|
|
|
|
private _getPlacerAt(x: number, y: number) {
|
|
if (!this._getPlacerFn) {
|
|
return;
|
|
}
|
|
|
|
const stamp = Date.now();
|
|
this._placerRequestTime = +stamp;
|
|
this._getPlacerAtWaiter(x, y, stamp);
|
|
}
|
|
|
|
private _showPlacerTag(placer: CanvasRecord): void {
|
|
this._placerTag = $('<div class="canvas__cursor-placer">');
|
|
this._placerTag.innerText = placer.user;
|
|
this._placerTag.style.setProperty('--base-size', `${this._zoom / 2}px`);
|
|
this._placerTag.style.setProperty('--base-scale', `${this._zoom / 100}`);
|
|
this._cursor.append(this._placerTag);
|
|
}
|
|
|
|
private _resetPlacerTag(): void {
|
|
this._placerRequestTime = 0;
|
|
this.picker.setPickColor(null);
|
|
if (this._placerTag) {
|
|
this._cursor.removeChild(this._placerTag);
|
|
this._placerTag = null;
|
|
}
|
|
}
|
|
|
|
private _resetPositionOrCenter(): void {
|
|
const search = window.location.search.substring(1);
|
|
if (!search?.length) {
|
|
this.center();
|
|
return;
|
|
}
|
|
|
|
const obj = new URLSearchParams(search);
|
|
const move = !!obj.get('px') || !!obj.get('py');
|
|
|
|
if (!move) {
|
|
return this.center();
|
|
}
|
|
|
|
if (obj.get('z')) {
|
|
this._zoom = clamp(parseFloat(obj.get('z')), 1, 100);
|
|
}
|
|
|
|
if (obj.get('px')) {
|
|
this._relcursorx = clamp(parseInt(obj.get('px'), 10), 0, this._size - 1);
|
|
}
|
|
|
|
if (obj.get('py')) {
|
|
this._relcursory = clamp(parseInt(obj.get('py'), 10), 0, this._size - 1);
|
|
}
|
|
|
|
this._cursorx = this._viewWidth / 2;
|
|
this._cursory = this._viewHeight / 2;
|
|
|
|
this._posx = -Math.ceil(this._relcursorx * this._zoom - this._cursorx) - 1;
|
|
this._posy = -Math.ceil(this._relcursory * this._zoom - this._cursory);
|
|
|
|
this.moveCanvas();
|
|
this.moveCursor();
|
|
}
|
|
}
|