/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import Scrollable from "../components/scrollable"; import rootScope from "../lib/rootScope"; import { animate } from "./animation"; import { drawCircleFromStart } from "./canvas/drawCircle"; import roundRect from "./canvas/roundRect"; import Shimmer from "./canvas/shimmer"; import customProperties from "./dom/customProperties"; import easeInOutSine from "./easing/easeInOutSine"; import mediaSizes from "./mediaSizes"; export default class DialogsPlaceholder { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private shimmer: Shimmer; private tempId: number; private detachTime: number; private length: number; private dialogHeight: number; private availableLength: number; private avatarSize: number; private marginVertical: number; private lineHeight: number; private lineBorderRadius: number; private lineMarginVertical: number; private statusWidth: number; private generatedValues: { firstLineWidth: number, secondLineWidth: number, statusWidth: number }[]; private getRectFrom: Element; private onRemove: () => void; private blockScrollable: Scrollable; constructor() { this.shimmer = new Shimmer(); this.tempId = 0; this.canvas = document.createElement('canvas'); this.canvas.classList.add('dialogs-placeholder-canvas'); this.ctx = this.canvas.getContext('2d'); this.generatedValues = []; this.avatarSize = 54; this.marginVertical = 9; this.lineHeight = 10; this.lineBorderRadius = 6; this.lineMarginVertical = 8; this.statusWidth = 24; } public attach({container, rect, getRectFrom, onRemove, blockScrollable}: { container: HTMLElement, rect?: {width: number, height: number}, getRectFrom?: HTMLElement, onRemove?: DialogsPlaceholder['onRemove'], blockScrollable?: DialogsPlaceholder['blockScrollable'] }) { const {canvas} = this; this.onRemove = onRemove; this.getRectFrom = getRectFrom || container; if(this.blockScrollable = blockScrollable) { blockScrollable.container.style.overflowY = 'hidden'; } this.updateCanvasSize(rect); this.startAnimation(); container.append(canvas); } public detach(availableLength: number) { if(this.detachTime) { return; } this.availableLength = availableLength; this.detachTime = Date.now(); if(!rootScope.settings.animationsEnabled) { this.remove(); } } public remove() { this.stopAnimation(); if(this.canvas.parentElement) { this.canvas.remove(); if(this.onRemove) { this.onRemove(); this.onRemove = undefined; } if(this.blockScrollable) { this.blockScrollable.container.style.overflowY = ''; this.blockScrollable = undefined; } } } private updateCanvasSize(rect: {width: number, height: number} = this.getRectFrom.getBoundingClientRect()) { const {canvas} = this; const dpr = canvas.dpr = window.devicePixelRatio; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; } private renderDetachAnimationFrame() { const { canvas, ctx, detachTime, length, availableLength } = this; if(!detachTime) { return; } else if(!rootScope.settings.animationsEnabled) { this.remove(); return; } const {width} = canvas; ctx.globalCompositeOperation = 'destination-out'; // ctx.fillStyle = 'rgba(0, 0, 0, 0)'; // ctx.fillRect(0, 0, width, height); // const DURATION = 500; // const DELAY = DURATION; const DURATION = 150; const DELAY = 15; const elapsedTime = Date.now() - detachTime; let completed = true; for(let i = 0; i < length; ++i) { const delay = availableLength < length && i >= availableLength ? DELAY * (availableLength - 1) : DELAY * i; const elapsedRowTime = elapsedTime - delay; if(elapsedRowTime <= 0) { completed = false; continue; } const progress = easeInOutSine(elapsedRowTime, 0, 1, DURATION); ctx.beginPath(); ctx.rect(0, this.dialogHeight * i, width, this.dialogHeight); ctx.fillStyle = `rgba(0, 0, 0, ${progress})`; ctx.fill(); if(progress < 1) { completed = false; } } // const totalRadius = Math.sqrt(width ** 2 + height ** 2); // const gradient = ctx.createRadialGradient( // 0, 0, 0, // 0, 0, totalRadius); // gradient.addColorStop(0, 'rgba(0, 0, 0, 1)'); // gradient.addColorStop(progress, 'rgba(0, 0, 0, 0)'); // gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); // const gradient = ctx.createLinearGradient(0, 0, 0, height); // gradient.addColorStop(0, 'rgba(0, 0, 0, 1)'); // gradient.addColorStop(progress, 'rgba(0, 0, 0, 0)'); // gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); // ctx.fillStyle = gradient; // ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = 'source-over'; if(completed) { this.remove(); } } private renderFrame() { this.shimmer.on(); this.renderDetachAnimationFrame(); } private startAnimation() { const {canvas, shimmer} = this; const tempId = ++this.tempId; const pattern = this.createPattern(); shimmer.settings({ canvas, fillStyle: pattern }); const middleware = () => { return this.tempId === tempId; }; this.renderFrame(); animate(() => { if(!middleware()) { return false; } // ! should've removed the loop if animations are disabled if(rootScope.settings.animationsEnabled) { this.renderFrame(); } // ! tempId can be changed during renderFrame return middleware(); }); rootScope.addEventListener('theme_change', this.onThemeChange); mediaSizes.addEventListener('resize', this.onResize); } private stopAnimation() { ++this.tempId; rootScope.removeEventListener('theme_change', this.onThemeChange); mediaSizes.removeEventListener('resize', this.onResize); } private onThemeChange = () => { this.stopAnimation(); this.startAnimation(); }; private onResize = () => { const {canvas} = this; const {width, height, dpr} = canvas; this.updateCanvasSize(); if(canvas.width === width && canvas.height === height && canvas.dpr === dpr) { return; } this.stopAnimation(); this.startAnimation(); }; private createPattern() { const {canvas, ctx} = this; const patternCanvas = document.createElement('canvas'); const patternContext = patternCanvas.getContext('2d'); const dpr = canvas.dpr; patternCanvas.dpr = dpr; patternCanvas.width = canvas.width; patternCanvas.height = canvas.height; patternContext.fillStyle = customProperties.getProperty('surface-color'); patternContext.fillRect(0, 0, patternCanvas.width, patternCanvas.height); patternContext.fillStyle = '#000'; patternContext.globalCompositeOperation = 'destination-out'; const dialogHeight = this.dialogHeight = (this.avatarSize + this.marginVertical * 2) * dpr; const length = this.length = Math.ceil(canvas.height / dialogHeight); for(let i = 0; i < length; ++i) { this.drawChat(patternContext, i, i * dialogHeight); } return ctx.createPattern(patternCanvas, 'no-repeat'); } private drawChat(ctx: CanvasRenderingContext2D, i: number, y: number) { let generatedValues = this.generatedValues[i]; if(!generatedValues) { generatedValues = this.generatedValues[i] = { firstLineWidth: 40 + Math.random() * 100, // 120 secondLineWidth: 120 + Math.random() * 130, // 240 statusWidth: 24 + Math.random() * 16, }; } const { firstLineWidth, secondLineWidth, statusWidth } = generatedValues; const {canvas} = ctx; const {dpr} = canvas; y /= dpr; const { avatarSize, marginVertical, lineHeight, lineBorderRadius, lineMarginVertical, } = this; let marginLeft = 17; drawCircleFromStart(ctx, marginLeft, y + marginVertical, avatarSize / 2, true); marginLeft += avatarSize + 10; roundRect(ctx, marginLeft, y + marginVertical + lineMarginVertical, firstLineWidth, lineHeight, lineBorderRadius, true); roundRect(ctx, marginLeft, y + marginVertical + avatarSize - lineHeight - lineMarginVertical, secondLineWidth, lineHeight, lineBorderRadius, true); roundRect(ctx, canvas.width / dpr - 24 - statusWidth, y + marginVertical + lineMarginVertical, statusWidth, lineHeight, lineBorderRadius, true); } }