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 { Message, Member, ShouldNeverHappenError } from '../data-types';
|
||||||
import Q from '../q-module';
|
import Q from '../q-module';
|
||||||
import createImageOverlay from './overlay-image';
|
|
||||||
import createImageContextMenu from './context-menu-img';
|
import createImageContextMenu from './context-menu-img';
|
||||||
import CombinedGuild from '../guild-combined';
|
import CombinedGuild from '../guild-combined';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactHelper from './require/react-helper';
|
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 {
|
export default function createImageResourceMessage(document: Document, q: Q, guild: CombinedGuild, message: Message): Element {
|
||||||
if (!message.resourceId || !message.resourcePreviewId || !message.resourceName) {
|
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) => {
|
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 () => {
|
(async () => {
|
||||||
(q.$$$(element, '.member-avatar img') as HTMLImageElement).src =
|
(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 CombinedGuild from '../../guild-combined';
|
||||||
import Q from '../../q-module';
|
import Q from '../../q-module';
|
||||||
import ReactHelper from './react-helper';
|
import ReactHelper from './react-helper';
|
||||||
|
import Overlay from '../components/overlay';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
export interface HTMLElementWithRemoveSelf extends HTMLElement {
|
export interface HTMLElementWithRemoveSelf extends HTMLElement {
|
||||||
removeSelf: (() => void);
|
removeSelf: (() => void);
|
||||||
@ -249,12 +251,16 @@ export default class BaseElements {
|
|||||||
return element as HTMLElementWithRemoveSelf;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
static createOverlay(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf {
|
static createOverlay(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf {
|
||||||
const q = new Q(document);
|
const q = new Q(document);
|
||||||
|
|
||||||
let wasDownInternal = false; // because 'click' fires on the overlay element anyway
|
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;
|
const element: HTMLElementWithRemoveSelf = ReactHelper.createElementFromJSX(<div className="overlay">{content}</div>) as HTMLElementWithRemoveSelf;
|
||||||
element.removeSelf = () => {
|
element.removeSelf = () => {
|
||||||
if (element.parentElement) {
|
if (element.parentElement) {
|
||||||
|
@ -87,6 +87,15 @@ export default class ElementsUtil {
|
|||||||
element.classList.remove('shaking-horizontal');
|
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> {
|
static async getImageBufferSrc(buffer: Buffer): Promise<string> {
|
||||||
const result = await FileType.fromBuffer(buffer);
|
const result = await FileType.fromBuffer(buffer);
|
||||||
switch (result && result.mime) {
|
switch (result && result.mime) {
|
||||||
|
@ -92,5 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
/* Popup Image Overlay */
|
/* Popup Image Overlay */
|
||||||
|
|
||||||
body > .overlay {
|
body > .overlay,
|
||||||
|
#react-overlays > .overlay {
|
||||||
/* Note: skip top 22px so we don't overlay on the title bar */
|
/* Note: skip top 22px so we don't overlay on the title bar */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100vw;
|
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 is a promise for error stack tracking purposes.
|
||||||
// this function expects the last argument of args to be a callback(err, serverData)
|
// this function expects the last argument of args to be a callback(err, serverData)
|
||||||
static socketEmitTimeout(
|
static socketEmitTimeout(
|
||||||
|
Loading…
Reference in New Issue
Block a user