react-based image popup

This commit is contained in:
Michael Peters 2021-12-06 23:06:59 -06:00
parent 6b17f569bb
commit 82546300cc
10 changed files with 283 additions and 4 deletions

View File

@ -0,0 +1,109 @@
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 electron from 'electron';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Resource, ShouldNeverHappenError } from '../../data-types';
import CombinedGuild from '../../guild-combined';
import Util from '../../util';
import Globals from '../../globals';
import path from 'path';
import * as fs from 'fs/promises';
import Button from './button';
import ElementsUtil from '../require/elements-util';
interface DownloadButtonProps {
/* Fetch based on resource id */
guild?: CombinedGuild,
resourceId?: string,
/* Pre-define the download buffer */
downloadBuff?: Buffer,
downloadBuffErr?: boolean,
resourceName: string
}
type ButtonText = 'Loading...' | 'Error' | 'Save' | 'Downloading...' | 'Writing...' | 'Reveal in Explorer' | 'Try Again';
const DownloadButton: FC<DownloadButtonProps> = (props: DownloadButtonProps) => {
const { guild, resourceId, downloadBuffErr, downloadBuff, resourceName } = props;
const [ downloading, setDownloading ] = useState<boolean>(false);
const [ downloadPath, setDownloadPath ] = useState<string | null>(null);
const [ buttonText, setButtonText ] = useState<ButtonText>('Save');
const [ buttonShaking, setButtonShaking ] = useState<boolean>(false);
useEffect(() => {
if (downloading === false && downloadPath === null) {
if (guild && resourceId) {
setButtonText('Save');
} else {
if (downloadBuff) {
setButtonText('Save');
} else if (downloadBuffErr) {
setButtonText('Error');
} else {
setButtonText('Loading...');
}
}
}
}, [ downloading, downloadPath, guild, resourceId, downloadBuff, downloadBuffErr, buttonText ])
useEffect(() => {
if (downloading === false && downloadPath === null && ((guild && resourceId) || (downloadBuff))) {
setButtonText('Save');
}
}, [ downloading, downloadPath, guild, resourceId, downloadBuff ]);
const downloadListener = useCallback(async () => {
if (downloading || buttonShaking) return;
if (downloadPath && await Util.exists(downloadPath)) {
electron.shell.showItemInFolder(downloadPath);
return;
}
setDownloading(true);
setButtonText('Downloading...');
let resourceBuff: Buffer;
if (downloadBuff) {
resourceBuff = downloadBuff;
} else {
try {
if (!guild) throw new ShouldNeverHappenError('guild 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');
resourceBuff = (await guild.fetchResource(resourceId)).data;
} catch (e: unknown) {
LOG.error('Error downloading resource', { e });
setDownloading(false);
setDownloadPath(null);
setButtonText('Try Again');
await ElementsUtil.delayToggleState(setButtonShaking, 400);
return;
}
}
try {
const availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, resourceName);
const availablePath = path.join(Globals.DOWNLOAD_DIR, availableName);
await fs.writeFile(availablePath, resourceBuff);
setDownloadPath(availablePath);
} catch (e: unknown) {
LOG.error('Error writing download file', e);
setDownloading(false);
setDownloadPath(null);
setButtonText('Try Again');
await ElementsUtil.delayToggleState(setButtonShaking, 400);
return;
}
setButtonText('Reveal in Explorer');
setDownloading(false);
}, [ downloading, buttonShaking, downloadPath, downloadBuff ]);
return <Button shaking={buttonShaking} onClick={downloadListener}>{buttonText}</Button>
}
export default DownloadButton;

View File

@ -0,0 +1,14 @@
import React, { FC } from 'react';
interface ButtonProps {
onClick: () => void
shaking?: boolean;
children: React.ReactNode
}
const Button: FC<ButtonProps> = (props: ButtonProps) => {
const { onClick, shaking, children } = props;
return <div className={'button ' + (shaking ? 'shaking-horizontal' : '')} onClick={onClick}>{children}</div>
}
export default Button;

View File

@ -0,0 +1,64 @@
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 React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
interface OverlayProps {
children: React.ReactNode;
}
const Overlay: FC<OverlayProps> = (props: OverlayProps) => {
const { children } = props;
const node = useRef<HTMLDivElement | null>(null);
const [ mouseDownInChild, setMouseDownInChild ] = useState<boolean>(false);
const [ mouseUpInChild, setMouseUpInChild ] = useState<boolean>(false);
const removeSelf = () => {
if (mouseDownInChild || mouseUpInChild) {
setMouseDownInChild(false);
setMouseUpInChild(false);
return;
}
if (node.current) {
ReactDOM.unmountComponentAtNode(node.current.parentElement as Element);
}
if (keyDownEventHandler) {
window.removeEventListener('keydown', keyDownEventHandler);
}
// otherwise, this isn't in the DOM anyway
};
const keyDownEventHandler = useMemo(() => {
const eventHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
removeSelf();
}
};
window.addEventListener('keydown', eventHandler);
return eventHandler;
}, []);
const checkMouseDown = (e: React.MouseEvent) => {
if (e.target === node.current) {
setMouseDownInChild(false);
} else {
setMouseDownInChild(true);
}
};
const checkMouseUp = (e: React.MouseEvent) => {
if (e.target === node.current) {
setMouseUpInChild(false);
} else {
setMouseUpInChild(true);
}
};
return <div className="overlay" ref={node} onMouseDown={checkMouseDown} onMouseUp={checkMouseUp} onClick={removeSelf}>{children}</div>
};
export default Overlay;

View File

@ -10,12 +10,13 @@ import ElementsUtil from './require/elements-util.js';
import { Message, Member, ShouldNeverHappenError } from '../data-types';
import Q from '../q-module';
import createImageOverlay from './overlay-image';
import createImageContextMenu from './context-menu-img';
import CombinedGuild from '../guild-combined';
import React from 'react';
import ReactHelper from './require/react-helper';
import BaseElements from './require/base-elements';
import ImageOverlay from './overlays/overlay-image';
export default function createImageResourceMessage(document: Document, q: Q, guild: CombinedGuild, message: Message): Element {
if (!message.resourceId || !message.resourcePreviewId || !message.resourceName) {
@ -61,7 +62,11 @@ export default function createImageResourceMessage(document: Document, q: Q, gui
);
q.$$$(element, '.content.image').addEventListener('click', (e) => {
document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string));
BaseElements.presentReactOverlay(document,
<ImageOverlay guild={guild}
resourceId={message.resourceId as string} resourceName={message.resourceName as string} />
);
//document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string));
});
(async () => {
(q.$$$(element, '.member-avatar img') as HTMLImageElement).src =

View File

@ -0,0 +1,63 @@
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 React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import CombinedGuild from '../../guild-combined';
import ElementsUtil from '../require/elements-util';
import { Resource } from '../../data-types';
import Button from '../components/button';
import DownloadButton from '../components/button-download';
type ButtonText = 'Loading...' | 'Error' | 'Save' | 'Downloading...' | 'Writing...' | 'Reveal in Explorer' | 'Try Again';
export interface ImageOverlayProps {
guild: CombinedGuild
resourceId: string,
resourceName: string
}
const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
const { guild, resourceId, resourceName } = props;
const [ resource, setResource ] = useState<Resource | null>(null);
const [ resourceImgSrc, setResourceImgSrc ] = useState<string>('./img/loading.svg');
const [ resourceErr, setResourceErr ] = useState<boolean>(false);
useEffect(() => {
(async () => {
try {
const resource = await guild.fetchResource(resourceId);
setResource(resource);
const resourceImgSrc = await ElementsUtil.getImageBufferSrc(resource.data);
setResourceImgSrc(resourceImgSrc);
} catch (e) {
LOG.error('unable to load image for overlay', e);
setResource(null);
setResourceImgSrc('./img/error.png');
setResourceErr(true);
}
})();
});
const sizeText = useMemo(() => resource ? ElementsUtil.humanSize(resource.data.length) : 'Loading Size...', [ resource ]);
return (
<div className="content popup-image" onClick={(e) => { e.stopPropagation(); /* prevent overlay click */ }}>
<img src={resourceImgSrc} alt={resourceName} title={resourceName}></img>
<div className="download">
<div className="info">
<div className="name">{resourceName}</div>
<div className="size">{sizeText}</div>
</div>
<DownloadButton
downloadBuff={resource?.data} downloadBuffErr={resourceErr}
resourceName={resourceName}></DownloadButton>
</div>
</div>
);
}
export default ImageOverlay;

View File

@ -14,6 +14,8 @@ import { Channel } from '../../data-types';
import CombinedGuild from '../../guild-combined';
import Q from '../../q-module';
import ReactHelper from './react-helper';
import Overlay from '../components/overlay';
import ReactDOM from 'react-dom';
export interface HTMLElementWithRemoveSelf extends HTMLElement {
removeSelf: (() => void);
@ -249,12 +251,16 @@ export default class BaseElements {
return element as HTMLElementWithRemoveSelf;
}
static presentReactOverlay(document: Document, content: JSX.Element) {
const overlay = <Overlay>{content}</Overlay>;
ReactDOM.render(overlay, document.querySelector('#react-overlays'));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static createOverlay(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf {
const q = new Q(document);
let wasDownInternal = false; // because 'click' fires on the overlay element anyway
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const element: HTMLElementWithRemoveSelf = ReactHelper.createElementFromJSX(<div className="overlay">{content}</div>) as HTMLElementWithRemoveSelf;
element.removeSelf = () => {
if (element.parentElement) {

View File

@ -87,6 +87,15 @@ export default class ElementsUtil {
element.classList.remove('shaking-horizontal');
}
// Calls a function with the start parameter and then the inverse of the start parameter after a determined number of ms
// There is no way to cancel this function
// Useful for enabling a "shake" element for a pre-determined amount of time
static async delayToggleState(setState: React.Dispatch<React.SetStateAction<boolean>>, delayMs: number, start = true): Promise<void> {
setState(start);
await Util.sleep(delayMs);
setState(old => !start);
}
static async getImageBufferSrc(buffer: Buffer): Promise<string> {
const result = await FileType.fromBuffer(buffer);
switch (result && result.mime) {

View File

@ -92,5 +92,7 @@
</div>
</div>
</div>
<!-- it's important that this comes at the end so that these take prescedence over the other absolutely positioned objects-->
<div id="react-overlays"></div>
</body>
</html>

View File

@ -2,7 +2,8 @@
/* Popup Image Overlay */
body > .overlay {
body > .overlay,
#react-overlays > .overlay {
/* Note: skip top 22px so we don't overlay on the title bar */
position: absolute;
width: 100vw;

View File

@ -127,6 +127,12 @@ export default class Util {
}
}
static async sleep(ms: number): Promise<unknown> {
return await new Promise((resolve, reject) => {
setTimeout(resolve, ms);
});
}
// This function is a promise for error stack tracking purposes.
// this function expects the last argument of args to be a callback(err, serverData)
static socketEmitTimeout(