cordis/client/webapp/elements/require/elements-util.ts
2021-11-01 23:29:24 -05:00

392 lines
17 KiB
TypeScript

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> | void);
downloadFailFunc?: ((message: string) => Promise<void> | void);
writeStartFunc: (() => Promise<void> | void);
writeFailFunc: ((e: Error | any) => Promise<void> | void);
successFunc: ((path: string) => Promise<void> | void);
}
async function sleep(ms: number): Promise<unknown> {
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<void> {
element.classList.add('shaking-horizontal');
await sleep(ms);
element.classList.remove('shaking-horizontal');
}
static async getImageBufferSrc(buffer: Buffer): Promise<string> {
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: any, resourceId: string): Promise<string> {
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 <span class="bold"/"italic"/"bold italic"/"underline"> 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<void>) {
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;
}
}
}