react-based image popup
This commit is contained in:
parent
6b17f569bb
commit
82546300cc
109
src/client/webapp/elements/components/button-download.tsx
Normal file
109
src/client/webapp/elements/components/button-download.tsx
Normal 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;
|
14
src/client/webapp/elements/components/button.tsx
Normal file
14
src/client/webapp/elements/components/button.tsx
Normal 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;
|
64
src/client/webapp/elements/components/overlay.tsx
Normal file
64
src/client/webapp/elements/components/overlay.tsx
Normal 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;
|
@ -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 =
|
||||
|
63
src/client/webapp/elements/overlays/overlay-image.tsx
Normal file
63
src/client/webapp/elements/overlays/overlay-image.tsx
Normal 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;
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user