import * as path from 'path'; import * as fs from 'fs/promises'; import * as electron from 'electron'; import * as electronRemote from '@electron/remote'; const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import * as FileType from 'file-type'; import Util from '../../util'; import Globals from '../../globals'; import ClientController from '../../client-controller'; import { ShouldNeverHappenError } from '../../data-types'; // TODO: pass-through Globals in init function // alignment: { // centerY: 'top' // left: 'right + 20' // } interface IAlignment { left?: string; centerX?: string; right?: string; top?: string; centerY?: string; bottom?: string; } interface IHTMLElementWithRemovalType extends HTMLElement { manualRemoval?: boolean; } interface CreateDownloadListenerProps { downloadBuff?: Buffer; server?: ClientController; resourceId?: string; resourceName: string; downloadStartFunc: (() => Promise | void); downloadFailFunc?: ((message: string) => Promise | void); writeStartFunc: (() => Promise | void); writeFailFunc: ((e: Error | any) => Promise | void); successFunc: ((path: string) => Promise | void); } async function sleep(ms: number): Promise { return await new Promise((resolve, reject) => { setTimeout(resolve, ms); }); } export default class ElementsUtil { static calendarFormats = { sameDay: '[Today at] HH:mm', lastDay: '[Yesterday at] HH:mm', lastWeek: '[Last] dddd [at] HH:mm', sameElse: 'MM/DD/YYYY [at] HH:mm' }; // from https://stackoverflow.com/q/10420352 static humanSize(bytes: number): string { if (bytes == 0) { return "0.00 B"; } var e = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'; } // See https://stackoverflow.com/q/1125292/ static setCursorToEnd(element: HTMLElement): void { let range = document.createRange(); range.selectNodeContents(element); range.collapse(false); // false for end rather than start let selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } // Shakes an element for specified ms static async shakeElement(element: HTMLElement, ms: number): Promise { element.classList.add('shaking-horizontal'); await sleep(ms); element.classList.remove('shaking-horizontal'); } static async getImageBufferSrc(buffer: Buffer): Promise { let result = await FileType.fromBuffer(buffer); switch (result && result.mime) { case 'image/png': case 'image/jpeg': case 'image/gif': //@ts-ignore This is a pretty gamer-level line return 'data:' + result.mime + ';base64,' + buffer.toString('base64'); default: // TODO: some images are not getting setup here... (webp, etc) LOG.warn('bad result for buffer:', { buffer, result }); throw new Error('invalid image buffer'); } } static async getImageBufferFromResourceFailSoftly(server: ClientController, resourceId: string | null): Promise { if (!resourceId) { LOG.warn('no server resource specified, showing error instead', new Error()); return './img/error.png'; } try { let resourceBuff = await server.fetchResource(resourceId); let src = await ElementsUtil.getImageBufferSrc(resourceBuff); return src; } catch (e) { LOG.warn('unable to fetch server resource, showing error instead', e); return './img/error.png'; } } // creates spans to format the text (all in q.js element markup) static parseMessageText(text: string): any { let obj = { tag: 'span', content: [] as any[], class: null }; let stack: any[] = [ obj ]; let idx = 0; function makeEscape(regex: RegExp, len: number, str: string): { matcher: RegExp, response: ((i: number) => void)} { // function for readability return { matcher: regex, response: (i: number) => { let top = stack[stack.length - 1]; if (idx != i) top.content.push(text.substring(idx, i)); top.content.push(str); idx = i + len; } } } function makeCase(regex: RegExp, len: number, cls: string): { matcher: RegExp, response: ((i: number) => void) } { // function for readability return { matcher: regex, response: (i: number) => { let top = stack[stack.length - 1]; if (idx != i) top.content.push(text.substring(idx, i)); if (top.class == cls) { // italic ends // TODO: optimise out empty elements stack.pop(); } else { // italic begins let obj = { tag: 'span', class: cls, content: [] } top.content.push(obj); stack.push(obj); } idx = i + len; } } } let cases = [ makeEscape(/^\\\\/, 2, '\\'), // simple \\ -> \ makeEscape(/^\\\*/, 2, '*'), // \* -> * makeEscape(/^\\\_/, 2, '_'), // \_ -> _ makeCase(/^\*\*\*/, 3, 'bold italic'), // to clear up ambiguity of *** makeCase(/^\*\*/, 2, 'bold'), makeCase(/^\*/, 1, 'italic'), makeCase(/^_/, 1, 'underline'), ]; while (idx != text.length) { let matched = false; eachIdx: for (let i = idx; i < text.length; ++i) { // yea, we're going high level labeled loops now B) let s = text.substr(i); for (let { matcher, response } of cases) { if (matcher.test(s)) { response(i); matched = true; break eachIdx; } } } if (!matched && idx != text.length) { // Add any remaining content let top = stack[stack.length - 1]; top.content.push(text.substr(idx)); idx = text.length; } } return obj; } // NOTE: both elements must be within the document or this function will not work // alignment: { // centerY: 'top' // left: 'right + 20' // } // means align the center of the element to the top of the relative to // and align the left of the element to the right of the relative to plus 20 pixels // relativeTo can be an { x, y } or HTMLElement static alignContextElement( element: HTMLElement, relativeTo: HTMLElement | { x: number, y: number }, alignment: IAlignment ): void { function getOffset(alignment, name) { let added = name + ' +'; let subbed = name + ' -'; if (alignment.startsWith(added)) { return parseInt(alignment.substr(added.length)); } else if (alignment.startsWith(subbed)) { return parseInt(alignment.substr(subbed.length)); } else if (alignment == name) { return 0; } else { throw new Error('illegal alignment: ' + alignment); } } let boundingRect: { left: number, right: number, top: number, bottom: number}; if (relativeTo instanceof HTMLElement) { boundingRect = relativeTo.getBoundingClientRect(); } else { boundingRect = { left: relativeTo.x, right: relativeTo.x, top: relativeTo.y, bottom: relativeTo.y, }; } let rl = boundingRect.left; let rt = boundingRect.top; let rr = boundingRect.right; let rb = boundingRect.bottom; let rw = boundingRect.right - boundingRect.left; let rh = boundingRect.bottom - boundingRect.top; let ew = element.offsetWidth; let eh = element.offsetHeight; if (alignment.centerY) { if (alignment.centerY.startsWith('centerY')) { // centerY of element with centerY of relative let offset = getOffset(alignment.centerY, 'centerY'); element.style.top = (rt + ((rh - eh) / 2) + offset) + 'px'; } else if (alignment.centerY.startsWith('top')) { // centerY of element with top of relative let offset = getOffset(alignment.centerY, 'top'); element.style.top = (rt - (eh / 2) + offset) + 'px'; } else if (alignment.centerY.startsWith('bottom')) { // centerY of element with bottom of relative let offset = getOffset(alignment.centerY, 'bottom'); element.style.top = (rt + (rh - (eh / 2)) + offset) + 'px'; } else { throw new Error('illegal centerY alignment: ' + JSON.stringify(alignment.centerY)); } } else if (alignment.top) { if (alignment.top.startsWith('centerY')) { // top of element with centerV of relative let offset = getOffset(alignment.top, 'centerY'); element.style.top = (rt + (rh / 2) + offset) + 'px'; } else if (alignment.top.startsWith('top')) { // top of element with top of relative let offset = getOffset(alignment.top, 'top'); element.style.top = (rt + offset) + 'px'; } else if (alignment.top.startsWith('bottom')) { // top of element with bottom of relative let offset = getOffset(alignment.top, 'bottom'); element.style.top = (rt + rh + offset) + 'px'; } else { throw new Error('illegal top alignment: ' + JSON.stringify(alignment.top)); } } else if (alignment.bottom) { if (alignment.bottom.startsWith('centerY')) { // bottom of element with centerV of relative let offset = getOffset(alignment.bottom, 'centerY'); element.style.top = (rb - (rh / 2) - eh + offset) + 'px'; } else if (alignment.bottom.startsWith('top')) { // bottom of element with top of relative let offset = getOffset(alignment.bottom, 'top'); element.style.top = (rb - rh - eh + offset) + 'px'; } else if (alignment.bottom.startsWith('bottom')) { // bottom of element with bottom of relative let offset = getOffset(alignment.bottom, 'bottom'); element.style.top = (rb - eh + offset) + 'px'; } else { throw new Error('illegal bottom alignment: ' + JSON.stringify(alignment.bottom)); } } else { throw new Error('y-alignment not specified'); } if (alignment.centerX) { if (alignment.centerX.startsWith('centerX')) { // centerX of element with centerX of relative let offset = getOffset(alignment.centerX, 'centerX'); element.style.left = (rl + ((rw - ew) / 2) + offset) + 'px'; } else if (alignment.centerX.startsWith('left')) { // centerX of element with left of relative let offset = getOffset(alignment.centerX, 'left'); element.style.left = (rl - (ew / 2) + offset) + 'px'; } else if (alignment.centerX.startsWith('right')) { // centerX of element with right of relative let offset = getOffset(alignment.centerX, 'right'); element.style.left = (rl + (rw - (ew / 2)) + offset) + 'px'; } else { throw new Error('illegal centerX alignment: ' + JSON.stringify(alignment.centerX)); } } else if (alignment.left) { if (alignment.left.startsWith('centerX')) { let offset = getOffset(alignment.left, 'centerX'); element.style.left = (rl + (rw / 2) + offset) + 'px'; } else if (alignment.left.startsWith('left')) { let offset = getOffset(alignment.left, 'left'); element.style.left = (rl + offset) + 'px'; } else if (alignment.left.startsWith('right')) { let offset = getOffset(alignment.left, 'right'); element.style.left = (rl + rw + offset) + 'px'; } else { throw new Error('illegal left alignment: ' + JSON.stringify(alignment.left)); } } else if (alignment.right) { if (alignment.right.startsWith('centerX')) { let offset = getOffset(alignment.right, 'centerX'); element.style.left = (rr - (rw / 2) - ew + offset) + 'px'; } else if (alignment.right.startsWith('left')) { let offset = getOffset(alignment.right, 'left'); element.style.left = (rr - rw - ew + offset) + 'px'; } else if (alignment.right.startsWith('right')) { let offset = getOffset(alignment.right, 'right'); element.style.left = (rr - ew + offset) + 'px'; } else { throw new Error('illegal right alignment: ' + JSON.stringify(alignment.right)); } } else { throw new Error('x-alignment not defined'); } } static bindHoverableContextElement( hoverElement: HTMLElement, contextElement: IHTMLElementWithRemovalType, rootElement: HTMLElement, alignment: IAlignment, neverRemove?: boolean ): void { hoverElement.addEventListener('mouseenter', () => { document.body.appendChild(contextElement); ElementsUtil.alignContextElement(contextElement, rootElement, alignment); }); if (neverRemove) { LOG.warn('hoverable context menu created with neverRemove flag set.'); return; } hoverElement.addEventListener('mouseleave', () => { if (contextElement.parentElement && !contextElement.manualRemoval) { contextElement.parentElement.removeChild(contextElement); } }); } static createDownloadListener(props: CreateDownloadListenerProps): (() => Promise) { const { downloadBuff, // pre-downloaded buffer to save rather than submit a download request (downloadStartFunc still required) server, resourceId, resourceName, downloadStartFunc, downloadFailFunc, writeStartFunc, writeFailFunc, successFunc } = props; let downloading = false; let downloadPath: string | null = null; return async () => { if (downloading) return; if (downloadPath && await Util.exists(downloadPath)) { electron.shell.showItemInFolder(downloadPath); return; } downloading = true; await downloadStartFunc(); let resourceBuff: Buffer; if (downloadBuff) { resourceBuff = downloadBuff; } else { if (!server) throw new ShouldNeverHappenError('server is null and we are not using a pre-download'); if (!resourceId) throw new ShouldNeverHappenError('resourceId is null and we are not using a pre-download'); try { resourceBuff = await server.fetchResource(resourceId); } catch (e) { LOG.error('Error downloading resource', e); if (downloadFailFunc) await downloadFailFunc(e); downloading = false; return; } } await writeStartFunc(); try { let availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, resourceName); downloadPath = path.join(Globals.DOWNLOAD_DIR, availableName); await fs.writeFile(downloadPath, resourceBuff); } catch (e) { LOG.error('Error writing download file', e); await writeFailFunc(e); downloadPath = null; downloading = false; return; } await successFunc(downloadPath); downloading = false; } } }