396 lines
14 KiB
TypeScript
396 lines
14 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: ClientController, resourceId: string | null): Promise<string> {
|
|
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 <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;
|
|
}
|
|
}
|
|
}
|