image overlay context menu

Also did a refactor on how overlays decide to close
This commit is contained in:
Michael Peters 2021-12-25 18:35:57 -06:00
parent 7fde5e70cf
commit 2bae6c72cd
13 changed files with 340 additions and 229 deletions

View File

@ -3,10 +3,11 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useEffect, useRef, useState } from 'react';
import React, { FC, useEffect, useRef } from 'react';
import ElementsUtil from '../require/elements-util';
import * as FileType from 'file-type';
import ReactHelper from '../require/react-helper';
interface ImageEditInputProps {
@ -26,29 +27,33 @@ const ImageEditInput: FC<ImageEditInputProps> = (props: ImageEditInputProps) =>
const acceptedExtTypes = [ 'png', 'jpg', 'jpeg' ];
const isMounted = ReactHelper.useIsMountedRef();
const imgRef = useRef<HTMLImageElement>(null);
const [ imgSrc, setImgSrc ] = useState<string>('./img/loading.svg');
useEffect(() => {
(async () => {
if (value) {
try {
const src = await ElementsUtil.getImageBufferSrc(value);
setImgSrc(src);
setValid(true);
} catch (e: unknown) {
LOG.error('unable to get image buffer src', e);
setImgSrc('./img/error.png');
setValid(false);
setMessage('Unable to get image src');
}
} else {
setImgSrc('./img/loading.svg');
const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => {
if (!value) {
setValid(false);
return './img/loading.svg';
}
})();
}, [ value ]);
try {
const src = await ElementsUtil.getImageBufferSrc(value);
if (!isMounted.current) return './img/error.png';
setValid(true);
return src;
} catch (e: unknown) {
LOG.error('unable to get image buffer src', e);
if (!isMounted.current) return './img/error.png';
setValid(false);
setMessage('Unable to get image src');
return './img/error.png';
}
},
'./img/loading.svg',
[ value ]
);
useEffect(() => {
if (imgRef.current) {

View File

@ -3,71 +3,37 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useMemo, useRef, useState } from "react";
import React, { FC, RefObject, useCallback, useEffect } from "react";
import ElementsUtil from '../require/elements-util';
import ReactHelper from '../require/react-helper';
interface OverlayProps {
document: Document;
childRootRef: RefObject<HTMLElement>; // clicks outside this ref will close the overlay
children: React.ReactNode;
}
const Overlay: FC<OverlayProps> = (props: OverlayProps) => {
const { document, children } = props;
const { childRootRef, 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;
}
// TODO: This is pretty messy and should be re-thought when we full-convert to react.
// The window event listener could be re-called if we call closeReactOverlay elsewhere...
if (keyDownHandler) {
window.removeEventListener('keydown', keyDownHandler);
}
if (node.current) {
ElementsUtil.closeReactOverlay(document);
}
// otherwise, this isn't in the DOM anyway
};
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);
}
};
const keyDownHandler = useMemo(() => {
const eventHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
removeSelf();
}
};
window.addEventListener('keydown', eventHandler);
return eventHandler;
const removeSelf = useCallback(() => {
ElementsUtil.closeReactOverlay(document);
}, []);
const clickHandler = (e: React.MouseEvent) => {
if (e.target === node.current) {
ReactHelper.useCloseWhenClickedOutsideEffect(childRootRef, () => { removeSelf(); });
const keyDownHandler = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
removeSelf();
}
}
}, [ removeSelf ]);
return <div className="overlay" ref={node} onMouseDown={checkMouseDown} onMouseUp={checkMouseUp} onClick={clickHandler}>{children}</div>
useEffect(() => {
window.addEventListener('keydown', keyDownHandler);
return () => {
window.removeEventListener('keydown', keyDownHandler);
}
}, []);
return <div className="overlay">{children}</div>
};
export default Overlay;

View File

@ -1,16 +1,18 @@
import React, { FC, ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { ShouldNeverHappenError } from '../../../data-types';
import ElementsUtil, { IAlignment } from '../../require/elements-util';
import ReactHelper from '../../require/react-helper';
export interface ContextMenuProps {
relativeToRef: RefObject<HTMLElement | null>;
relativeToRef?: RefObject<HTMLElement | null>;
relativeToPos?: { x: number, y: number };
alignment: IAlignment;
children: ReactNode;
close: () => void;
}
const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
const { relativeToRef, alignment, children, close } = props;
const { relativeToRef, relativeToPos, alignment, children, close } = props;
const rootRef = useRef<HTMLDivElement>(null);
@ -19,10 +21,12 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
ReactHelper.useCloseWhenClickedOutsideEffect(rootRef, close);
useEffect(() => {
if (!rootRef.current || !relativeToRef.current) return;
ElementsUtil.alignContextElement(rootRef.current, relativeToRef.current, alignment);
if (!rootRef.current) return;
const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null;
if (!relativeTo) throw new ShouldNeverHappenError('invalid context menu props');
ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment);
setAligned(true);
}, [ rootRef, relativeToRef ]);
}, [ rootRef, relativeToRef, relativeToPos ]);
const contextClass = useMemo(() => {
return 'context react' + (aligned ? ' aligned' : '');

View File

@ -0,0 +1,69 @@
import React, { FC } from 'react';
import * as electron from 'electron';
import ReactHelper from '../require/react-helper';
import ContextMenu from './components/context-menu';
import * as FileType from 'file-type';
import sharp from 'sharp';
import { IAlignment } from '../require/elements-util';
export interface ImageContextMenuProps {
alignment: IAlignment;
relativeToPos: { x: number, y: number };
close: () => void;
resourceName: string;
resourceBuff: Buffer;
isPreview: boolean;
}
const ImageContextMenu: FC<ImageContextMenuProps> = (props: ImageContextMenuProps) => {
const { alignment, relativeToPos, close, resourceName, resourceBuff, isPreview } = props;
const previewText = isPreview ? ' Preview' : '';
// TODO: Integrate shaking
const [ copyCallable, copyText, copyShaking ] = ReactHelper.useAsyncSubmitButton(
async () => {
const type = await FileType.fromBuffer(resourceBuff);
if (!type) {
return { result: null, errorMessage: 'Unable to get file type' };
}
let nativeImage: electron.NativeImage;
if (type.mime !== 'image/png' && type.mime !== 'image/jpeg') {
// TODO: Copy+Paste GIFs somehow
nativeImage = electron.nativeImage.createFromBuffer(await sharp(resourceBuff).png().toBuffer());
} else {
nativeImage = electron.nativeImage.createFromBuffer(resourceBuff);
}
electron.clipboard.writeImage(nativeImage);
return { result: null, errorMessage: null };
},
[ resourceBuff ],
{
start: 'Copy Image' + previewText,
pending: 'Copying' + previewText,
error: 'Copy Failed. Click to Try Again',
done: 'Copied to Clipboard'
}
);
const [ saveCallable, saveText, saveShaking ] = ReactHelper.useDownloadButton(
resourceName, resourceBuff,
{
start: 'Save Image' + previewText,
pendingSave: 'Saving' + previewText + '...',
errorSave: 'Save Failed. Click to Try Again'
}
);
return (
<ContextMenu alignment={alignment} relativeToPos={relativeToPos} close={close}>
<div className="image">
<div className="item copy-image" onClick={copyCallable}>{copyText}</div>
<div className="item save-image" onClick={saveCallable}>{saveText}</div>
</div>
</ContextMenu>
);
}
export default ImageContextMenu;

View File

@ -52,13 +52,13 @@ const PreviewImageElement: FC<PreviewImageElementProps> = (props: PreviewImageEl
const [ imgSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, resourcePreviewId);
const clickCallback = useCallback(() => {
const openImageOverlay = useCallback(() => {
// Note: document here isn't 100% guaranteed but we should be getting rid of this eventually anyway
ElementsUtil.presentReactOverlay(document, <ImageOverlay guild={guild} resourceId={resourceId} resourceName={resourceName} />);
}, [ guild, resourceId, resourceName ]);
return (
<div className="content image" style={{ width: previewWidth, height: previewHeight }} onClick={clickCallback}>
<div className="content image" style={{ width: previewWidth, height: previewHeight }} onClick={openImageOverlay}>
<img src={imgSrc} alt={resourceName}/>
</div>
);

View File

@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useEffect, useMemo, useState } from 'react';
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import GuildsManager from '../../guilds-manager';
import moment from 'moment';
import TextInput from '../components/input-text';
@ -18,6 +18,7 @@ import InvitePreview from '../components/invite-preview';
import ReactHelper from '../require/react-helper';
import * as fs from 'fs/promises';
import Button from '../components/button';
import Overlay from '../components/overlay';
export interface IAddGuildData {
name: string,
@ -60,6 +61,8 @@ export interface AddGuildOverlayProps {
const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps) => {
const { document, ui, guildsManager, addGuildData } = props;
const rootRef = useRef<HTMLDivElement>(null);
const expired = addGuildData.expires < new Date().getTime();
const exampleDisplayName = useMemo(() => getExampleDisplayName(), []);
const exampleAvatarPath = useMemo(() => getExampleAvatarPath(), []);
@ -121,34 +124,36 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
}, [ validationErrorMessage, submitFailMessage ]);
return (
<div className="content add-guild">
<InvitePreview
name={addGuildData.name} iconSrc={addGuildData.iconSrc}
url={addGuildData.url} expiresFromNow={moment.duration(addGuildData.expires - Date.now(), 'ms')}
/>
<div className="divider"></div>
<div className="personalization">
<div className="avatar">
<ImageEditInput
maxSize={Globals.MAX_AVATAR_SIZE}
value={avatarBuff} setValue={setAvatarBuff}
setValid={setAvatarInputValid} setMessage={setAvatarInputMessage}
/>
</div>
<div className="display-name">
<TextInput
label="Display Name" placeholder={exampleDisplayName}
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitFunc}
/>
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content add-guild">
<InvitePreview
name={addGuildData.name} iconSrc={addGuildData.iconSrc}
url={addGuildData.url} expiresFromNow={moment.duration(addGuildData.expires - Date.now(), 'ms')}
/>
<div className="divider"></div>
<div className="personalization">
<div className="avatar">
<ImageEditInput
maxSize={Globals.MAX_AVATAR_SIZE}
value={avatarBuff} setValue={setAvatarBuff}
setValid={setAvatarInputValid} setMessage={setAvatarInputMessage}
/>
</div>
<div className="display-name">
<TextInput
label="Display Name" placeholder={exampleDisplayName}
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitFunc}
/>
</div>
</div>
<SubmitOverlayLower errorMessage={errorMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
<SubmitOverlayLower errorMessage={errorMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
</Overlay>
);
}

View File

@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { createRef, FC, useEffect, useMemo, useState } from 'react';
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import CombinedGuild from '../../guild-combined';
import BaseElements from '../require/base-elements';
import TextInput from '../components/input-text';
@ -13,6 +13,7 @@ import ElementsUtil from '../require/elements-util';
import { Channel } from '../../data-types';
import ReactHelper from '../require/react-helper';
import Button from '../components/button';
import Overlay from '../components/overlay';
export interface ChannelOverlayProps {
guild: CombinedGuild;
@ -21,7 +22,8 @@ export interface ChannelOverlayProps {
const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) => {
const { guild, channel } = props;
const nameInputRef = createRef<HTMLInputElement>();
const rootRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const [ edited, setEdited ] = useState<boolean>(false);
@ -94,38 +96,40 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) =>
}, [ validationErrorMessage, submitFailMessage ]);
return (
<div className="content submit-dialog modify-channel">
<div className="preview channel-title">
<div className="channel-icon">{BaseElements.TEXT_CHANNEL_ICON}</div>
<div className="channel-name">{name}</div>
<div className="channel-flavor-divider"></div>
<div className="channel-flavor-text">{flavorText}</div>
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content submit-dialog modify-channel">
<div className="preview channel-title">
<div className="channel-icon">{BaseElements.TEXT_CHANNEL_ICON}</div>
<div className="channel-name">{name}</div>
<div className="channel-flavor-divider"></div>
<div className="channel-flavor-text">{flavorText}</div>
</div>
<div className="channel-name">
<TextInput
ref={nameInputRef}
label="Channel Name" placeholder={channel?.name ?? 'channel-name'}
maxLength={Globals.MAX_CHANNEL_NAME_LENGTH}
value={name} setValue={setName}
setValid={setNameInputValid} setMessage={setNameInputMessage}
valueMap={value => value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')}
onEnterKeyDown={submitFunc}
/>
</div>
<div className="flavor-text">
<TextInput
label="Flavor Text" placeholder={channel?.flavorText ?? '(optional)'}
maxLength={Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH}
allowEmpty={true}
value={flavorText} setValue={setFlavorText}
setValid={setFlavorTextInputValid} setMessage={setFlavorTextInputMessage}
onEnterKeyDown={submitFunc}
/>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
<div className="channel-name">
<TextInput
ref={nameInputRef}
label="Channel Name" placeholder={channel?.name ?? 'channel-name'}
maxLength={Globals.MAX_CHANNEL_NAME_LENGTH}
value={name} setValue={setName}
setValid={setNameInputValid} setMessage={setNameInputMessage}
valueMap={value => value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')}
onEnterKeyDown={submitFunc}
/>
</div>
<div className="flavor-text">
<TextInput
label="Flavor Text" placeholder={channel?.flavorText ?? '(optional)'}
maxLength={Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH}
allowEmpty={true}
value={flavorText} setValue={setFlavorText}
setValid={setFlavorTextInputValid} setMessage={setFlavorTextInputMessage}
onEnterKeyDown={submitFunc}
/>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
</Overlay>
);
}

View File

@ -1,4 +1,5 @@
import React, { FC } from 'react';
import React, { FC, useRef } from 'react';
import Overlay from '../components/overlay';
export interface ErrorMessageOverlayProps {
title: string;
@ -7,16 +8,20 @@ export interface ErrorMessageOverlayProps {
const ErrorMessageOverlay: FC<ErrorMessageOverlayProps> = (props: ErrorMessageOverlayProps) => {
const { title, message } = props;
const rootRef = useRef<HTMLDivElement>(null);
return (
<div className="content error-message">
<div className="icon">
<img src="./img/error.png" alt="error" />
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content error-message">
<div className="icon">
<img src="./img/error.png" alt="error" />
</div>
<div className="text">
<div className="title">{title}</div>
<div className="message">{message}</div>
</div>
</div>
<div className="text">
<div className="title">{title}</div>
<div className="message">{message}</div>
</div>
</div>
</Overlay>
);
}

View File

@ -3,12 +3,13 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useEffect, useState } from "react";
import React, { FC, useEffect, useRef, useState } from "react";
import CombinedGuild from "../../guild-combined";
import ChoicesControl from "../components/control-choices";
import GuildInvitesDisplay from "../displays/display-guild-invites";
import GuildOverviewDisplay from "../displays/display-guild-overview";
import { GuildMetadata } from '../../data-types';
import Overlay from '../components/overlay';
export interface GuildSettingsOverlayProps {
guild: CombinedGuild;
@ -17,6 +18,8 @@ export interface GuildSettingsOverlayProps {
const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSettingsOverlayProps) => {
const { guild, guildMeta } = props;
const rootRef = useRef<HTMLDivElement>(null);
const [ selectedId, setSelectedId ] = useState<string>('overview');
const [ display, setDisplay ] = useState<JSX.Element>();
@ -27,14 +30,16 @@ const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSetting
}, [ selectedId ]);
return (
<div className="content display-swapper guild-settings">
<ChoicesControl title={guildMeta.name} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
{ id: 'overview', display: 'Overview' },
{ id: 'roles', display: 'Roles' },
{ id: 'invites', display: 'Invites' },
]} />
{display}
</div>
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content display-swapper guild-settings">
<ChoicesControl title={guildMeta.name} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
{ id: 'overview', display: 'Overview' },
{ id: 'roles', display: 'Roles' },
{ id: 'invites', display: 'Invites' },
]} />
{display}
</div>
</Overlay>
);
}

View File

@ -3,16 +3,14 @@ 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 React, { FC, useMemo } from 'react';
import React, { FC, useCallback, useMemo, useState, MouseEvent, useRef } from 'react';
import CombinedGuild from '../../guild-combined';
import ElementsUtil from '../require/elements-util';
import DownloadButton from '../components/button-download';
import createImageContextMenu from '../context-menu-img';
import Q from '../../q-module';
import ReactHelper from '../require/react-helper';
import GuildSubscriptions from '../require/guild-subscriptions';
import ImageContextMenu from '../context-menus/context-menu-image';
import Overlay from '../components/overlay';
export interface ImageOverlayProps {
guild: CombinedGuild
@ -24,50 +22,54 @@ const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
const { guild, resourceId, resourceName } = props;
// TODO: Handle errors
const rootRef = useRef<HTMLDivElement>(null);
const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId);
// Note: We want this customization here since we need the resource buffer.
const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useOneTimeAsyncAction(
const [ resourceImgSrc ] = ReactHelper.useOneTimeAsyncAction(
async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null),
'./img/loading.svg',
[ guild, resource ]
)
const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useOneTimeAsyncAction(
async () => {
if (!resource) return null;
const fileTypeInfo = (await FileType.fromBuffer(resource.data)) ?? null;
if (fileTypeInfo === null) throw new Error('unable to get mime/ext');
return fileTypeInfo;
},
null,
[ resource ]
);
const onImageContextMenu = (e: React.MouseEvent) => {
// TODO: This should be in react!
if (!resource) return;
if (!resourceFileTypeInfo) return;
const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, resourceFileTypeInfo.mime, resourceFileTypeInfo.ext, false);
document.body.appendChild(contextMenu);
const relativeTo = { x: e.pageX, y: e.pageY };
ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'right' });
};
const [ contextMenuOpen, setContextMenuOpen ] = useState<boolean>(false);
const alignment = useMemo(() => ({ top: 'centerY', left: 'centerX' }), []);
const [ relativeToPos, setRelativeToPos ] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
const contextMenu = useMemo(() => {
if (!resource) return null;
return (
<ImageContextMenu
alignment={alignment} relativeToPos={relativeToPos} close={() => { setContextMenuOpen(false); }}
resourceName={resourceName} resourceBuff={resource.data} isPreview={false}
/>
);
}, [ resource, alignment, relativeToPos, resourceName ]);
const toggleContextMenu = useCallback((e: MouseEvent<HTMLImageElement>) => {
LOG.debug('toggle context menu');
setRelativeToPos({ x: e.clientX, y: e.clientY });
setContextMenuOpen(oldContextMenuOpen => !!contextMenu && !oldContextMenuOpen);
}, [ contextMenu ]);
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} onContextMenu={onImageContextMenu}></img>
<div className="download">
<div className="info">
<div className="name">{resourceName}</div>
<div className="size">{sizeText}</div>
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content popup-image">
<img src={resourceImgSrc} alt={resourceName} title={resourceName} onContextMenu={toggleContextMenu}></img>
<div className="download">
<div className="info">
<div className="name">{resourceName}</div>
<div className="size">{sizeText}</div>
</div>
<DownloadButton
downloadBuff={resource?.data} downloadBuffErr={!!resourceError}
resourceName={resourceName}></DownloadButton>
</div>
<DownloadButton
downloadBuff={resource?.data} downloadBuffErr={!!resourceError}
resourceName={resourceName}></DownloadButton>
{contextMenuOpen ? contextMenu : null}
</div>
</div>
</Overlay>
);
}

View File

@ -3,8 +3,8 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useState } from 'react';
import { ConnectionInfo, Member } from '../../data-types';
import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useRef, useState } from 'react';
import { Member } from '../../data-types';
import Globals from '../../globals';
import CombinedGuild from '../../guild-combined';
import ImageEditInput from '../components/input-image-edit';
@ -14,18 +14,17 @@ import ElementsUtil from '../require/elements-util';
import GuildSubscriptions from '../require/guild-subscriptions';
import ReactHelper from '../require/react-helper';
import Button from '../components/button';
import Overlay from '../components/overlay';
export interface PersonalizeOverlayProps {
document: Document;
guild: CombinedGuild;
selfMember: ConnectionInfo | Member;
selfMember: Member;
}
const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverlayProps) => {
const { document, guild, selfMember } = props;
if (selfMember.avatarResourceId === null) {
throw new Error('bad avatar');
}
const rootRef = useRef<HTMLDivElement>(null);
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, selfMember.avatarResourceId)
@ -109,30 +108,32 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
}, [ validationErrorMessage, submitFailMessage ]);
return (
<div className="content personalize">
<div className="personalization">
<div className="avatar">
<ImageEditInput
maxSize={Globals.MAX_AVATAR_SIZE}
value={avatarBuff} setValue={setAvatarBuff}
setValid={setAvatarInputValid} setMessage={setAvatarInputMessage}
/>
</div>
<div className="display-name">
<TextInput
ref={displayNameInputRef}
label="Display Name" placeholder={savedDisplayName}
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitFunc}
/>
<Overlay childRootRef={rootRef}>
<div ref={rootRef} className="content personalize">
<div className="personalization">
<div className="avatar">
<ImageEditInput
maxSize={Globals.MAX_AVATAR_SIZE}
value={avatarBuff} setValue={setAvatarBuff}
setValid={setAvatarInputValid} setMessage={setAvatarInputMessage}
/>
</div>
<div className="display-name">
<TextInput
ref={displayNameInputRef}
label="Display Name" placeholder={savedDisplayName}
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitFunc}
/>
</div>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
</Overlay>
);
}

View File

@ -17,7 +17,6 @@ import CombinedGuild from '../../guild-combined';
import { ShouldNeverHappenError } from '../../data-types';
import React from 'react';
import Overlay from '../components/overlay';
import ReactDOM from 'react-dom';
export interface IAlignment {
@ -378,9 +377,14 @@ export default class ElementsUtil {
ReactDOM.unmountComponentAtNode(element);
}
static presentReactOverlay(document: Document, content: JSX.Element) {
const overlay = <Overlay document={document}>{content}</Overlay>;
ReactDOM.render(overlay, document.querySelector('#react-overlays'));
static presentReactOverlay(document: Document, overlay: JSX.Element) {
// for aids reasons, the click event gets sent through to the overlay so we're just adding a sleep
// here to break the event loop. Hopefully this gets better when we don't have to do a seperate render piece.
// and we handle overlays through 100% react
(async () => {
await Util.sleep(0);
ReactDOM.render(overlay, document.querySelector('#react-overlays'));
})();
}
static closeReactOverlay(document: Document) {

View File

@ -138,7 +138,7 @@ export default class ReactHelper {
static useDownloadButton(
downloadName: string,
downloadSrc: { guild: CombinedGuild, resourceId: string } | Buffer,
stateTextMapping?: { start?: string, pendingFetch: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string }
stateTextMapping?: { start?: string, pendingFetch?: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string }
): [ callable: () => void, text: string, shaking: boolean ] {
const textMapping = { ...{ start: 'Download', pendingFetch: 'Downloading...', errorFetch: 'Try Again', pendingSave: 'Saving...', errorSave: 'Try Again', success: 'Open in Explorer' }, ...stateTextMapping };
@ -338,20 +338,61 @@ export default class ReactHelper {
return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ];
}
// Makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside
static useCloseWhenClickedOutsideEffect(ref: RefObject<HTMLElement>, close: () => void) {
const [ mouseDownTarget, setMouseDownTarget ] = useState<EventTarget | null>(null);
const [ mouseUpTarget, setMouseUpTarget ] = useState<EventTarget | null>(null);
const handleClickOutside = useCallback((event: MouseEvent) => {
//console.log('current:', ref.current, 'target:', event.target, 'mouseDownTarget:', mouseDownTarget, 'mouseUpTarget:', mouseUpTarget);
if (!ref.current) return;
// Casting here is OK. https://stackoverflow.com/q/61164018
if (ref.current.contains(event.target as Node)) return;
if (mouseDownTarget !== null || mouseUpTarget !== null) return;
close();
}, [ ref, close ]);
}, [ ref, mouseDownTarget, mouseUpTarget, close ]);
const handleMouseDown = useCallback((event: MouseEvent) => {
if (!ref.current) return;
if (ref.current.contains(event.target as Node)) {
setMouseDownTarget(event.target);
} else {
setMouseDownTarget(null);
}
}, [ ref ]);
const handleMouseUp = useCallback((event: MouseEvent) => {
if (!ref.current) return;
if (ref.current.contains(event.target as Node)) {
setMouseUpTarget(event.target);
} else {
setMouseUpTarget(null);
}
}, [ ref ]);
useEffect(() => {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}
}, [ handleClickOutside ]);
useEffect(() => {
document.addEventListener('mousedown', handleMouseDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
}
}, [ handleMouseDown ]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.addEventListener('mouseup', handleMouseUp);
}
}, [ handleMouseUp ]);
}
}
}