import { convertHex } from '../common/convert'; import { clamp, debounce } from '../common/helper'; import { CanvasRecord, Placement } from '../common/types/canvas'; import { IcyNetUser } from '../server/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; private _canvas = $('') as HTMLCanvasElement; private _ctx = this._canvas.getContext('2d'); private _wrapper = $('
'); private _container = $('
'); private _cursor = $('
'); private _coods = $('
'); 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(); // Zoom multiplier // 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 = Math.floor((this._cursorx - screenX) / this._zoom); this._relcursory = Math.floor((this._cursory - screenY) / this._zoom); 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 > 30) { this._getPlacerAt(this._relcursorx, this._relcursory); } } public initialize(): void { this.picker.initialize(); this._wrapper.append(this._coods); this._container.append(this._canvas); this._wrapper.append(this._cursor); this._wrapper.append(this._container); 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._dragging = false; this._pinching = false; this._previousPinchLength = 0; }); 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, ): void { this._getPlacerFn = fn; } public setUser(user: IcyNetUser): void { this._user = user; this.picker.setLoggedIn(user); } 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) => { console.log(this._placerRequestTime); if (this._placerRequestTime === order) { this._getPlacerFn(x, y).then((placer) => { if (placer && this._placerRequestTime === order) { this._showPlacerTag(placer); } }); } }, 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 = $('
'); 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; 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); } if (obj.get('py')) { this._relcursory = clamp(parseInt(obj.get('py'), 10), 0, this._size); } 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(); } }