image overlay context menu
Also did a refactor on how overlays decide to close
This commit is contained in:
parent
7fde5e70cf
commit
2bae6c72cd
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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' : '');
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 ]);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user