Add image editor
This commit is contained in:
parent
86d09917a3
commit
7affe313f0
58 changed files with 4261 additions and 173 deletions
|
@ -5,7 +5,9 @@ import type { CSSProperties, MouseEventHandler, ReactNode } from 'react';
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Theme } from '../util/theme';
|
||||
import { assert } from '../util/assert';
|
||||
import { themeClassName } from '../util/theme';
|
||||
|
||||
export enum ButtonSize {
|
||||
Large,
|
||||
|
@ -41,6 +43,7 @@ type PropsType = {
|
|||
size?: ButtonSize;
|
||||
style?: CSSProperties;
|
||||
tabIndex?: number;
|
||||
theme?: Theme;
|
||||
variant?: ButtonVariant;
|
||||
} & (
|
||||
| {
|
||||
|
@ -97,6 +100,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
|||
icon,
|
||||
style,
|
||||
tabIndex,
|
||||
theme,
|
||||
variant = ButtonVariant.Primary,
|
||||
size = variant === ButtonVariant.Details
|
||||
? ButtonSize.Small
|
||||
|
@ -120,7 +124,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
|||
const variantClassName = VARIANT_CLASS_NAMES.get(variant);
|
||||
assert(variantClassName, '<Button> variant not found');
|
||||
|
||||
return (
|
||||
const buttonElement = (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={classNames(
|
||||
|
@ -142,5 +146,11 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
|||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (theme) {
|
||||
return <div className={themeClassName(theme)}>{buttonElement}</div>;
|
||||
}
|
||||
|
||||
return buttonElement;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -55,6 +55,10 @@ import {
|
|||
useAttachFileShortcut,
|
||||
useKeyboardShortcuts,
|
||||
} from '../hooks/useKeyboardShortcuts';
|
||||
import { MediaEditor } from './MediaEditor';
|
||||
import { IMAGE_PNG } from '../types/MIME';
|
||||
import { isImageTypeSupported } from '../util/GoogleChrome';
|
||||
import { canEditImages } from '../util/canEditImages';
|
||||
|
||||
export type CompositionAPIType =
|
||||
| {
|
||||
|
@ -253,6 +257,9 @@ export const CompositionArea = ({
|
|||
const [disabled, setDisabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [large, setLarge] = useState(false);
|
||||
const [attachmentToEdit, setAttachmentToEdit] = useState<
|
||||
AttachmentDraftType | undefined
|
||||
>();
|
||||
const inputApiRef = useRef<InputApi | undefined>();
|
||||
const fileInputRef = useRef<null | HTMLInputElement>(null);
|
||||
|
||||
|
@ -286,6 +293,19 @@ export const CompositionArea = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const hasImageEditingEnabled = canEditImages();
|
||||
|
||||
function maybeEditAttachment(attachment: AttachmentDraftType) {
|
||||
if (
|
||||
!hasImageEditingEnabled ||
|
||||
!isImageTypeSupported(attachment.contentType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachmentToEdit(attachment);
|
||||
}
|
||||
|
||||
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker);
|
||||
useKeyboardShortcuts(attachFileShortcut);
|
||||
|
||||
|
@ -560,6 +580,26 @@ export const CompositionArea = ({
|
|||
|
||||
return (
|
||||
<div className="CompositionArea">
|
||||
{attachmentToEdit && 'url' in attachmentToEdit && attachmentToEdit.url && (
|
||||
<MediaEditor
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentToEdit.url}
|
||||
onClose={() => setAttachmentToEdit(undefined)}
|
||||
onDone={data => {
|
||||
const newAttachment = {
|
||||
...attachmentToEdit,
|
||||
contentType: IMAGE_PNG,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
};
|
||||
|
||||
addAttachment(conversationId, newAttachment);
|
||||
setAttachmentToEdit(undefined);
|
||||
}}
|
||||
installedPacks={installedPacks}
|
||||
recentStickers={recentStickers}
|
||||
/>
|
||||
)}
|
||||
<div className="CompositionArea__toggle-large">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -607,8 +647,10 @@ export const CompositionArea = ({
|
|||
<div className="CompositionArea__attachment-list">
|
||||
<AttachmentList
|
||||
attachments={draftAttachments}
|
||||
canEditImages={hasImageEditingEnabled}
|
||||
i18n={i18n}
|
||||
onAddAttachment={launchAttachmentPicker}
|
||||
onClickAttachment={maybeEditAttachment}
|
||||
onClose={onClearAttachments}
|
||||
onCloseAttachment={attachment => {
|
||||
if (attachment.path) {
|
||||
|
|
39
ts/components/ContextMenu.stories.tsx
Normal file
39
ts/components/ContextMenu.stories.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './ContextMenu';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/ContextMenu', module);
|
||||
|
||||
const getDefaultProps = (): PropsType<number> => ({
|
||||
i18n,
|
||||
menuOptions: [
|
||||
{
|
||||
label: '1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '2',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '3',
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
onChange: action('onChange'),
|
||||
value: 1,
|
||||
});
|
||||
|
||||
story.add('Default', () => {
|
||||
return <ContextMenu {...getDefaultProps()} />;
|
||||
});
|
186
ts/components/ContextMenu.tsx
Normal file
186
ts/components/ContextMenu.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import type { Theme } from '../util/theme';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { themeClassName } from '../util/theme';
|
||||
|
||||
type OptionType<T> = {
|
||||
readonly icon?: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
readonly value: T;
|
||||
};
|
||||
|
||||
export type PropsType<T> = {
|
||||
readonly buttonClassName?: string;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly menuOptions: ReadonlyArray<OptionType<T>>;
|
||||
readonly onChange: (value: T) => unknown;
|
||||
readonly theme?: Theme;
|
||||
readonly title?: string;
|
||||
readonly value: T;
|
||||
};
|
||||
|
||||
export const ContextMenu = <T extends unknown>({
|
||||
buttonClassName,
|
||||
i18n,
|
||||
menuOptions,
|
||||
onChange,
|
||||
theme,
|
||||
title,
|
||||
value,
|
||||
}: PropsType<T>): JSX.Element => {
|
||||
const [menuShowing, setMenuShowing] = useState<boolean>(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
// We use regular MouseEvent below, and this one uses React.MouseEvent
|
||||
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
|
||||
setMenuShowing(true);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (!menuShowing) {
|
||||
if (ev.key === 'Enter') {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowDown') {
|
||||
const currFocusedIndex = focusedIndex || 0;
|
||||
const nextFocusedIndex =
|
||||
currFocusedIndex >= menuOptions.length - 1 ? 0 : currFocusedIndex + 1;
|
||||
setFocusedIndex(nextFocusedIndex);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowUp') {
|
||||
const currFocusedIndex = focusedIndex || 0;
|
||||
const nextFocusedIndex =
|
||||
currFocusedIndex === 0 ? menuOptions.length - 1 : currFocusedIndex - 1;
|
||||
setFocusedIndex(nextFocusedIndex);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter') {
|
||||
if (focusedIndex !== undefined) {
|
||||
onChange(menuOptions[focusedIndex].value);
|
||||
}
|
||||
setMenuShowing(false);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMenuShowing(false);
|
||||
setFocusedIndex(undefined);
|
||||
}, [setMenuShowing]);
|
||||
|
||||
const [referenceElement, setReferenceElement] =
|
||||
useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuShowing) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (!referenceElement?.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}, [menuShowing, handleClose, referenceElement]);
|
||||
|
||||
return (
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<button
|
||||
aria-label={i18n('ContextMenu--button')}
|
||||
className={classNames(buttonClassName, {
|
||||
ContextMenu__button: true,
|
||||
'ContextMenu__button--active': menuShowing,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
/>
|
||||
{menuShowing && (
|
||||
<div
|
||||
className="ContextMenu__popper"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className="ContextMenu__title">{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames({
|
||||
ContextMenu__option: true,
|
||||
'ContextMenu__option--focused': focusedIndex === index,
|
||||
})}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setMenuShowing(false);
|
||||
}}
|
||||
>
|
||||
<div className="ContextMenu__option--container">
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
'ContextMenu__option--icon',
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="ContextMenu__option--title">
|
||||
{option.label}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="ContextMenu__option--description">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{value === option.value ? (
|
||||
<div className="ContextMenu__option--selected" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -430,12 +430,8 @@ story.add('Archive: searching a conversation', () => (
|
|||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: defaultConversations,
|
||||
searchConversation: defaultConversations[0],
|
||||
searchTerm: 'foo bar',
|
||||
conversationResults: { isLoading: true },
|
||||
contactResults: { isLoading: true },
|
||||
messageResults: { isLoading: true },
|
||||
primarySendsSms: false,
|
||||
searchConversation: undefined,
|
||||
searchTerm: '',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
46
ts/components/MediaEditor.stories.tsx
Normal file
46
ts/components/MediaEditor.stories.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './MediaEditor';
|
||||
import { MediaEditor } from './MediaEditor';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/MediaEditor', module);
|
||||
|
||||
const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg';
|
||||
const IMAGE_2 = '/fixtures/tina-rolf-269345-unsplash.jpg';
|
||||
const IMAGE_3 = '/fixtures/kitten-4-112-112.jpg';
|
||||
const IMAGE_4 = '/fixtures/snow.jpg';
|
||||
|
||||
const getDefaultProps = (): PropsType => ({
|
||||
i18n,
|
||||
imageSrc: IMAGE_2,
|
||||
onClose: action('onClose'),
|
||||
onDone: action('onDone'),
|
||||
|
||||
// StickerButtonProps
|
||||
installedPacks,
|
||||
recentStickers: [Stickers.wide, Stickers.tall, Stickers.abe],
|
||||
});
|
||||
|
||||
story.add('Extra Large', () => <MediaEditor {...getDefaultProps()} />);
|
||||
|
||||
story.add('Large', () => (
|
||||
<MediaEditor {...getDefaultProps()} imageSrc={IMAGE_1} />
|
||||
));
|
||||
|
||||
story.add('Smol', () => (
|
||||
<MediaEditor {...getDefaultProps()} imageSrc={IMAGE_3} />
|
||||
));
|
||||
|
||||
story.add('Portrait', () => (
|
||||
<MediaEditor {...getDefaultProps()} imageSrc={IMAGE_4} />
|
||||
));
|
934
ts/components/MediaEditor.tsx
Normal file
934
ts/components/MediaEditor.tsx
Normal file
|
@ -0,0 +1,934 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Measure from 'react-measure';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fabric } from 'fabric';
|
||||
import { get, has, noop } from 'lodash';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { Props as StickerButtonProps } from './stickers/StickerButton';
|
||||
import type { ImageStateType } from '../mediaEditor/ImageStateType';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { Slider } from './Slider';
|
||||
import { StickerButton } from './stickers/StickerButton';
|
||||
import { Theme } from '../util/theme';
|
||||
import { canvasToBytes } from '../util/canvasToBytes';
|
||||
import { useFabricHistory } from '../mediaEditor/useFabricHistory';
|
||||
import { usePortal } from '../hooks/usePortal';
|
||||
import { useUniqueId } from '../hooks/useUniqueId';
|
||||
|
||||
import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush';
|
||||
import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect';
|
||||
import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText';
|
||||
import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker';
|
||||
import { getRGBA, getHSL } from '../mediaEditor/util/color';
|
||||
import {
|
||||
TextStyle,
|
||||
getTextStyleAttributes,
|
||||
} from '../mediaEditor/util/getTextStyleAttributes';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
imageSrc: string;
|
||||
onClose: () => unknown;
|
||||
onDone: (data: Uint8Array) => unknown;
|
||||
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>;
|
||||
|
||||
enum EditMode {
|
||||
Crop = 'Crop',
|
||||
Draw = 'Draw',
|
||||
Text = 'Text',
|
||||
}
|
||||
|
||||
enum DrawWidth {
|
||||
Thin = 2,
|
||||
Regular = 4,
|
||||
Medium = 12,
|
||||
Heavy = 24,
|
||||
}
|
||||
|
||||
enum DrawTool {
|
||||
Pen = 'Pen',
|
||||
Highlighter = 'Highlighter',
|
||||
}
|
||||
|
||||
export const MediaEditor = ({
|
||||
i18n,
|
||||
imageSrc,
|
||||
onClose,
|
||||
onDone,
|
||||
|
||||
// StickerButtonProps
|
||||
installedPacks,
|
||||
recentStickers,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
|
||||
const [image, setImage] = useState<HTMLImageElement>(new Image());
|
||||
|
||||
const isRestoringImageState = useRef(false);
|
||||
|
||||
const canvasId = useUniqueId();
|
||||
|
||||
const [imageState, setImageState] = useState<ImageStateType>({
|
||||
angle: 0,
|
||||
cropX: 0,
|
||||
cropY: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
});
|
||||
|
||||
// Initial image load and Fabric canvas setup
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImage(img);
|
||||
|
||||
const canvas = new fabric.Canvas(canvasId);
|
||||
canvas.selection = false;
|
||||
setFabricCanvas(canvas);
|
||||
setImageState(curr => ({
|
||||
...curr,
|
||||
height: img.height,
|
||||
width: img.width,
|
||||
}));
|
||||
};
|
||||
img.onerror = () => {
|
||||
// This is a bad experience, but it should be impossible.
|
||||
log.error('<MediaEditor>: image failed to load. Closing');
|
||||
onClose();
|
||||
};
|
||||
img.src = imageSrc;
|
||||
return () => {
|
||||
img.onload = noop;
|
||||
img.onerror = noop;
|
||||
};
|
||||
}, [canvasId, imageSrc, onClose]);
|
||||
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
function handleKeydown(ev: KeyboardEvent) {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Delete') {
|
||||
if (!obj.excludeFromExport) {
|
||||
fabricCanvas.remove(obj);
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
fabricCanvas.discardActiveObject();
|
||||
fabricCanvas.requestRenderAll();
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [fabricCanvas]);
|
||||
|
||||
const history = useFabricHistory(fabricCanvas);
|
||||
|
||||
// Take a snapshot of history whenever imageState changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
!imageState.height ||
|
||||
!imageState.width ||
|
||||
isRestoringImageState.current
|
||||
) {
|
||||
isRestoringImageState.current = false;
|
||||
return;
|
||||
}
|
||||
history?.takeSnapshot(imageState);
|
||||
}, [history, imageState]);
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
|
||||
const zoom =
|
||||
Math.min(
|
||||
containerWidth / imageState.width,
|
||||
containerHeight / imageState.height
|
||||
) || 1;
|
||||
|
||||
// Update the canvas dimensions (and therefore zoom)
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas || !imageState.width || !imageState.height) {
|
||||
return;
|
||||
}
|
||||
fabricCanvas.setDimensions({
|
||||
width: imageState.width * zoom,
|
||||
height: imageState.height * zoom,
|
||||
});
|
||||
fabricCanvas.setZoom(zoom);
|
||||
}, [
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
fabricCanvas,
|
||||
imageState.height,
|
||||
imageState.width,
|
||||
zoom,
|
||||
]);
|
||||
|
||||
// Refresh the background image according to imageState changes
|
||||
useEffect(() => {
|
||||
const backgroundImage = new fabric.Image(image, {
|
||||
canvas: fabricCanvas,
|
||||
height: imageState.height || image.height,
|
||||
width: imageState.width || image.width,
|
||||
});
|
||||
|
||||
let left: number;
|
||||
let top: number;
|
||||
switch (imageState.angle) {
|
||||
case 0:
|
||||
left = 0;
|
||||
top = 0;
|
||||
break;
|
||||
case 90:
|
||||
left = imageState.width;
|
||||
top = 0;
|
||||
break;
|
||||
case 180:
|
||||
left = imageState.width;
|
||||
top = imageState.height;
|
||||
break;
|
||||
case 270:
|
||||
left = 0;
|
||||
top = imageState.height;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unexpected angle');
|
||||
}
|
||||
|
||||
let { height, width } = imageState;
|
||||
if (imageState.angle % 180) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
|
||||
fabricCanvas?.setBackgroundImage(
|
||||
backgroundImage,
|
||||
fabricCanvas.requestRenderAll.bind(fabricCanvas),
|
||||
{
|
||||
angle: imageState.angle,
|
||||
cropX: imageState.cropX,
|
||||
cropY: imageState.cropY,
|
||||
flipX: imageState.flipX,
|
||||
flipY: imageState.flipY,
|
||||
left,
|
||||
top,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
width,
|
||||
height,
|
||||
}
|
||||
);
|
||||
}, [fabricCanvas, image, imageState]);
|
||||
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [cropAspectRatioLock, setcropAspectRatioLock] = useState(false);
|
||||
const [drawTool, setDrawTool] = useState<DrawTool>(DrawTool.Pen);
|
||||
const [drawWidth, setDrawWidth] = useState<DrawWidth>(DrawWidth.Regular);
|
||||
const [editMode, setEditMode] = useState<EditMode | undefined>();
|
||||
const [sliderValue, setSliderValue] = useState<number>(0);
|
||||
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
|
||||
|
||||
// Check if we can undo/redo & restore the image state on undo/undo
|
||||
useEffect(() => {
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
function refreshUndoState() {
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanUndo(history.canUndo());
|
||||
setCanRedo(history.canRedo());
|
||||
}
|
||||
|
||||
function restoreImageState(prevImageState?: ImageStateType) {
|
||||
if (prevImageState) {
|
||||
isRestoringImageState.current = true;
|
||||
setImageState(prevImageState);
|
||||
}
|
||||
}
|
||||
|
||||
history.on('historyChanged', refreshUndoState);
|
||||
history.on('appliedState', restoreImageState);
|
||||
|
||||
return () => {
|
||||
history.off('historyChanged', refreshUndoState);
|
||||
history.off('appliedState', restoreImageState);
|
||||
};
|
||||
}, [history]);
|
||||
|
||||
// If you select a text path auto enter edit mode
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateEditMode(ev: fabric.IEvent) {
|
||||
if (ev.target?.get('type') === 'MediaEditorFabricIText') {
|
||||
setEditMode(EditMode.Text);
|
||||
} else if (editMode === EditMode.Text) {
|
||||
setEditMode(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
fabricCanvas.on('selection:created', updateEditMode);
|
||||
fabricCanvas.on('selection:updated', updateEditMode);
|
||||
fabricCanvas.on('selection:cleared', updateEditMode);
|
||||
|
||||
return () => {
|
||||
fabricCanvas.off('selection:created', updateEditMode);
|
||||
fabricCanvas.off('selection:updated', updateEditMode);
|
||||
fabricCanvas.off('selection:cleared', updateEditMode);
|
||||
};
|
||||
}, [editMode, fabricCanvas]);
|
||||
|
||||
// Ensure scaling is in locked|unlocked state only when cropping
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode === EditMode.Crop) {
|
||||
fabricCanvas.uniformScaling = cropAspectRatioLock;
|
||||
} else {
|
||||
fabricCanvas.uniformScaling = true;
|
||||
}
|
||||
}, [cropAspectRatioLock, editMode, fabricCanvas]);
|
||||
|
||||
// Remove any blank text when edit mode changes off of text
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode !== EditMode.Text) {
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
if (obj && has(obj, 'text') && get(obj, 'text') === '') {
|
||||
fabricCanvas.remove(obj);
|
||||
}
|
||||
}
|
||||
}, [editMode, fabricCanvas]);
|
||||
|
||||
// Toggle draw mode
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode !== EditMode.Draw) {
|
||||
fabricCanvas.isDrawingMode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
fabricCanvas.discardActiveObject();
|
||||
fabricCanvas.isDrawingMode = true;
|
||||
|
||||
const freeDrawingBrush = new MediaEditorFabricPencilBrush(fabricCanvas);
|
||||
if (drawTool === DrawTool.Highlighter) {
|
||||
freeDrawingBrush.color = getRGBA(sliderValue, 0.5);
|
||||
freeDrawingBrush.strokeLineCap = 'square';
|
||||
freeDrawingBrush.strokeLineJoin = 'miter';
|
||||
freeDrawingBrush.width = (drawWidth / zoom) * 2;
|
||||
} else {
|
||||
freeDrawingBrush.color = getHSL(sliderValue);
|
||||
freeDrawingBrush.strokeLineCap = 'round';
|
||||
freeDrawingBrush.strokeLineJoin = 'bevel';
|
||||
freeDrawingBrush.width = drawWidth / zoom;
|
||||
}
|
||||
fabricCanvas.freeDrawingBrush = freeDrawingBrush;
|
||||
|
||||
fabricCanvas.requestRenderAll();
|
||||
}, [drawTool, drawWidth, editMode, fabricCanvas, sliderValue, zoom]);
|
||||
|
||||
// Change text style
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode !== EditMode.Text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
|
||||
if (!obj || !(obj instanceof MediaEditorFabricIText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
obj.set(getTextStyleAttributes(textStyle, sliderValue));
|
||||
fabricCanvas.requestRenderAll();
|
||||
}, [editMode, fabricCanvas, sliderValue, textStyle]);
|
||||
|
||||
// Create the CroppingRect
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode === EditMode.Crop) {
|
||||
const PADDING = MediaEditorFabricCropRect.PADDING / zoom;
|
||||
// For reasons we don't understand, height and width on small images doesn't work
|
||||
// right (it bleeds out) so we decrease them for small images.
|
||||
const height =
|
||||
imageState.height - PADDING * Math.max(440 / imageState.height, 2);
|
||||
const width =
|
||||
imageState.width - PADDING * Math.max(440 / imageState.width, 2);
|
||||
|
||||
let rect: MediaEditorFabricCropRect;
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
|
||||
if (obj instanceof MediaEditorFabricCropRect) {
|
||||
rect = obj;
|
||||
rect.set({ height, width, scaleX: 1, scaleY: 1 });
|
||||
} else {
|
||||
rect = new MediaEditorFabricCropRect({
|
||||
height,
|
||||
width,
|
||||
});
|
||||
|
||||
rect.on('deselected', () => {
|
||||
setEditMode(undefined);
|
||||
});
|
||||
|
||||
fabricCanvas.add(rect);
|
||||
fabricCanvas.setActiveObject(rect);
|
||||
}
|
||||
|
||||
fabricCanvas.viewportCenterObject(rect);
|
||||
rect.setCoords();
|
||||
} else {
|
||||
fabricCanvas.getObjects().forEach(obj => {
|
||||
if (obj instanceof MediaEditorFabricCropRect) {
|
||||
fabricCanvas.remove(obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [editMode, fabricCanvas, imageState.height, imageState.width, zoom]);
|
||||
|
||||
// In an ideal world we'd use <ModalHost /> to get the nice animation benefits
|
||||
// but because of the way IText is implemented -- with a hidden textarea -- to
|
||||
// capture keyboard events, we can't use ModalHost since that traps focus, and
|
||||
// focus trapping doesn't play nice with fabric's IText.
|
||||
const portal = usePortal();
|
||||
|
||||
if (!portal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tooling: JSX.Element | undefined;
|
||||
if (editMode === EditMode.Text) {
|
||||
tooling = (
|
||||
<>
|
||||
<Slider
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="MediaEditor__hue-slider"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
<ContextMenu
|
||||
buttonClassName={classNames('MediaEditor__button--text', {
|
||||
'MediaEditor__button--text-regular':
|
||||
textStyle === TextStyle.Regular,
|
||||
'MediaEditor__button--text-highlight':
|
||||
textStyle === TextStyle.Highlight,
|
||||
'MediaEditor__button--text-outline':
|
||||
textStyle === TextStyle.Outline,
|
||||
})}
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'MediaEditor__icon--text-regular',
|
||||
label: i18n('MediaEditor__text--regular'),
|
||||
value: TextStyle.Regular,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--text-highlight',
|
||||
label: i18n('MediaEditor__text--highlight'),
|
||||
value: TextStyle.Highlight,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--text-outline',
|
||||
label: i18n('MediaEditor__text--outline'),
|
||||
value: TextStyle.Outline,
|
||||
},
|
||||
]}
|
||||
onChange={value => setTextStyle(value)}
|
||||
theme={Theme.Dark}
|
||||
value={textStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (editMode === EditMode.Draw) {
|
||||
tooling = (
|
||||
<>
|
||||
<Slider
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="MediaEditor__hue-slider"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
<ContextMenu
|
||||
buttonClassName={classNames('MediaEditor__button--draw', {
|
||||
'MediaEditor__button--draw-pen': drawTool === DrawTool.Pen,
|
||||
'MediaEditor__button--draw-highlighter':
|
||||
drawTool === DrawTool.Highlighter,
|
||||
})}
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'MediaEditor__icon--draw-pen',
|
||||
label: i18n('MediaEditor__draw--pen'),
|
||||
value: DrawTool.Pen,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--draw-highlighter',
|
||||
label: i18n('MediaEditor__draw--highlighter'),
|
||||
value: DrawTool.Highlighter,
|
||||
},
|
||||
]}
|
||||
onChange={value => setDrawTool(value)}
|
||||
theme={Theme.Dark}
|
||||
value={drawTool}
|
||||
/>
|
||||
<ContextMenu
|
||||
buttonClassName={classNames('MediaEditor__button--width', {
|
||||
'MediaEditor__button--width-thin': drawWidth === DrawWidth.Thin,
|
||||
'MediaEditor__button--width-regular':
|
||||
drawWidth === DrawWidth.Regular,
|
||||
'MediaEditor__button--width-medium': drawWidth === DrawWidth.Medium,
|
||||
'MediaEditor__button--width-heavy': drawWidth === DrawWidth.Heavy,
|
||||
})}
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'MediaEditor__icon--width-thin',
|
||||
label: i18n('MediaEditor__draw--thin'),
|
||||
value: DrawWidth.Thin,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--width-regular',
|
||||
label: i18n('MediaEditor__draw--regular'),
|
||||
value: DrawWidth.Regular,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--width-medium',
|
||||
label: i18n('MediaEditor__draw--medium'),
|
||||
value: DrawWidth.Medium,
|
||||
},
|
||||
{
|
||||
icon: 'MediaEditor__icon--width-heavy',
|
||||
label: i18n('MediaEditor__draw--heavy'),
|
||||
value: DrawWidth.Heavy,
|
||||
},
|
||||
]}
|
||||
onChange={value => setDrawWidth(value)}
|
||||
theme={Theme.Dark}
|
||||
value={drawWidth}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (editMode === EditMode.Crop) {
|
||||
const canReset =
|
||||
imageState.cropX !== 0 ||
|
||||
imageState.cropY !== 0 ||
|
||||
imageState.flipX ||
|
||||
imageState.flipY ||
|
||||
imageState.angle !== 0;
|
||||
|
||||
tooling = (
|
||||
<div className="MediaEditor__crop-toolbar">
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__crop--reset')}
|
||||
className="MediaEditor__crop-toolbar--button MediaEditor__crop-toolbar--reset"
|
||||
disabled={!canReset}
|
||||
onClick={async () => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImageState({
|
||||
angle: 0,
|
||||
cropX: 0,
|
||||
cropY: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{i18n('MediaEditor__crop--reset')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__crop--rotate')}
|
||||
className="MediaEditor__crop-toolbar--button MediaEditor__crop-toolbar--rotate"
|
||||
onClick={() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
fabricCanvas.getObjects().forEach(obj => {
|
||||
if (obj instanceof MediaEditorFabricCropRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const center = obj.getCenterPoint();
|
||||
obj.set('angle', (imageState.angle + 270) % 360);
|
||||
obj.setPositionByOrigin(
|
||||
new fabric.Point(center.y, imageState.width - center.x),
|
||||
'center',
|
||||
'center'
|
||||
);
|
||||
obj.setCoords();
|
||||
});
|
||||
|
||||
setImageState(curr => ({
|
||||
...curr,
|
||||
angle: (curr.angle + 270) % 360,
|
||||
height: curr.width,
|
||||
width: curr.height,
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__crop--flip')}
|
||||
className="MediaEditor__crop-toolbar--button MediaEditor__crop-toolbar--flip"
|
||||
onClick={() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImageState(curr => ({
|
||||
...curr,
|
||||
...(curr.angle % 180
|
||||
? { flipY: !curr.flipY }
|
||||
: { flipX: !curr.flipX }),
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__crop--lock')}
|
||||
className={classNames('MediaEditor__crop-toolbar--button', {
|
||||
'MediaEditor__crop-toolbar--locked': cropAspectRatioLock,
|
||||
'MediaEditor__crop-toolbar--unlocked': !cropAspectRatioLock,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (fabricCanvas) {
|
||||
fabricCanvas.uniformScaling = !cropAspectRatioLock;
|
||||
}
|
||||
setcropAspectRatioLock(!cropAspectRatioLock);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__crop--crop')}
|
||||
className="MediaEditor__crop-toolbar--button MediaEditor__crop-toolbar--crop"
|
||||
onClick={() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cropRect = fabricCanvas.getActiveObject();
|
||||
|
||||
if (!(cropRect instanceof MediaEditorFabricCropRect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, height, top, width } = cropRect.getBoundingRect(true);
|
||||
|
||||
setImageState(curr => {
|
||||
let cropX: number;
|
||||
let cropY: number;
|
||||
switch (curr.angle) {
|
||||
case 0:
|
||||
cropX = curr.cropX + left;
|
||||
cropY = curr.cropY + top;
|
||||
break;
|
||||
case 90:
|
||||
cropX = curr.cropX + top;
|
||||
cropY = curr.cropY + (curr.width - (left + width));
|
||||
break;
|
||||
case 180:
|
||||
cropX = curr.cropX + (curr.width - (left + width));
|
||||
cropY = curr.cropY + (curr.height - (top + height));
|
||||
break;
|
||||
case 270:
|
||||
cropX = curr.cropX + (curr.height - (top + height));
|
||||
cropY = curr.cropY + left;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unexpected angle');
|
||||
}
|
||||
|
||||
return {
|
||||
...curr,
|
||||
cropX,
|
||||
cropY,
|
||||
height,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
fabricCanvas.getObjects().forEach(obj => {
|
||||
const { x, y } = obj.getCenterPoint();
|
||||
|
||||
const translatedCenter = new fabric.Point(x - left, y - top);
|
||||
obj.setPositionByOrigin(translatedCenter, 'center', 'center');
|
||||
obj.setCoords();
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{i18n('MediaEditor__crop--crop')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="MediaEditor">
|
||||
<div className="MediaEditor__container">
|
||||
<Measure
|
||||
bounds
|
||||
onResize={({ bounds }) => {
|
||||
if (!bounds) {
|
||||
log.error('We should be measuring the bounds');
|
||||
return;
|
||||
}
|
||||
setContainerWidth(bounds.width);
|
||||
setContainerHeight(bounds.height);
|
||||
}}
|
||||
>
|
||||
{({ measureRef }) => (
|
||||
<div className="MediaEditor__media" ref={measureRef}>
|
||||
{image && (
|
||||
<div>
|
||||
<canvas
|
||||
className={classNames('MediaEditor__media--canvas', {
|
||||
'MediaEditor__media--canvas--cropping':
|
||||
editMode === EditMode.Crop,
|
||||
})}
|
||||
id={canvasId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
</div>
|
||||
<div className="MediaEditor__toolbar">
|
||||
{tooling ? (
|
||||
<div className="MediaEditor__tools">{tooling}</div>
|
||||
) : (
|
||||
<div className="MediaEditor__toolbar--space" />
|
||||
)}
|
||||
<div className="MediaEditor__toolbar--buttons">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('discard')}
|
||||
</Button>
|
||||
<div className="MediaEditor__controls">
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__control--draw')}
|
||||
className={classNames({
|
||||
MediaEditor__control: true,
|
||||
'MediaEditor__control--pen': true,
|
||||
'MediaEditor__control--selected': editMode === EditMode.Draw,
|
||||
})}
|
||||
onClick={() => {
|
||||
setEditMode(
|
||||
editMode === EditMode.Draw ? undefined : EditMode.Draw
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__control--text')}
|
||||
className={classNames({
|
||||
MediaEditor__control: true,
|
||||
'MediaEditor__control--text': true,
|
||||
'MediaEditor__control--selected': editMode === EditMode.Text,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode === EditMode.Text) {
|
||||
setEditMode(undefined);
|
||||
} else {
|
||||
const FONT_SIZE_RELATIVE_TO_CANVAS = 10;
|
||||
const fontSize =
|
||||
Math.min(imageState.width, imageState.height) /
|
||||
FONT_SIZE_RELATIVE_TO_CANVAS;
|
||||
const text = new MediaEditorFabricIText('', {
|
||||
...getTextStyleAttributes(textStyle, sliderValue),
|
||||
fontSize,
|
||||
});
|
||||
text.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
imageState.width / 2,
|
||||
imageState.height / 2
|
||||
),
|
||||
'center',
|
||||
'center'
|
||||
);
|
||||
text.setCoords();
|
||||
fabricCanvas.add(text);
|
||||
fabricCanvas.setActiveObject(text);
|
||||
|
||||
text.enterEditing();
|
||||
setEditMode(EditMode.Text);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<StickerButton
|
||||
blessedPacks={[]}
|
||||
className={classNames({
|
||||
MediaEditor__control: true,
|
||||
'MediaEditor__control--sticker': true,
|
||||
})}
|
||||
clearInstalledStickerPack={noop}
|
||||
clearShowIntroduction={() => {
|
||||
// We're using this as a callback for when the sticker button
|
||||
// is pressed.
|
||||
fabricCanvas?.discardActiveObject();
|
||||
setEditMode(undefined);
|
||||
}}
|
||||
clearShowPickerHint={noop}
|
||||
i18n={i18n}
|
||||
installedPacks={installedPacks}
|
||||
knownPacks={[]}
|
||||
onPickSticker={(_packId, _stickerId, src: string) => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const STICKER_SIZE_RELATIVE_TO_CANVAS = 4;
|
||||
const size =
|
||||
Math.min(imageState.width, imageState.height) /
|
||||
STICKER_SIZE_RELATIVE_TO_CANVAS;
|
||||
|
||||
const sticker = new MediaEditorFabricSticker(src);
|
||||
sticker.scaleToHeight(size);
|
||||
sticker.setPositionByOrigin(
|
||||
new fabric.Point(imageState.width / 2, imageState.height / 2),
|
||||
'center',
|
||||
'center'
|
||||
);
|
||||
sticker.setCoords();
|
||||
|
||||
fabricCanvas.add(sticker);
|
||||
fabricCanvas.setActiveObject(sticker);
|
||||
setEditMode(undefined);
|
||||
}}
|
||||
receivedPacks={[]}
|
||||
recentStickers={recentStickers}
|
||||
showPickerHint={false}
|
||||
theme={Theme.Dark}
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__control--crop')}
|
||||
className={classNames({
|
||||
MediaEditor__control: true,
|
||||
'MediaEditor__control--crop': true,
|
||||
'MediaEditor__control--selected': editMode === EditMode.Crop,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
if (editMode === EditMode.Crop) {
|
||||
const obj = fabricCanvas.getActiveObject();
|
||||
if (obj instanceof MediaEditorFabricCropRect) {
|
||||
fabricCanvas.remove(obj);
|
||||
}
|
||||
setEditMode(undefined);
|
||||
} else {
|
||||
setEditMode(EditMode.Crop);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__control--undo')}
|
||||
className="MediaEditor__control MediaEditor__control--undo"
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
if (editMode === EditMode.Crop) {
|
||||
setEditMode(undefined);
|
||||
}
|
||||
history?.undo();
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MediaEditor__control--redo')}
|
||||
className="MediaEditor__control MediaEditor__control--redo"
|
||||
disabled={!canRedo}
|
||||
onClick={() => {
|
||||
if (editMode === EditMode.Crop) {
|
||||
setEditMode(undefined);
|
||||
}
|
||||
history?.redo();
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!fabricCanvas) {
|
||||
return;
|
||||
}
|
||||
const renderedCanvas = fabricCanvas.toCanvasElement();
|
||||
const data = await canvasToBytes(renderedCanvas);
|
||||
onDone(data);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portal
|
||||
);
|
||||
};
|
|
@ -43,7 +43,7 @@ story.add('One File', () => {
|
|||
}),
|
||||
],
|
||||
});
|
||||
return <AttachmentList {...props} />;
|
||||
return <AttachmentList {...props} canEditImages />;
|
||||
});
|
||||
|
||||
story.add('Multiple Visual Attachments', () => {
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
|
||||
export type Props = Readonly<{
|
||||
attachments: ReadonlyArray<AttachmentDraftType>;
|
||||
canEditImages?: boolean;
|
||||
i18n: LocalizerType;
|
||||
onAddAttachment?: () => void;
|
||||
onClickAttachment?: (attachment: AttachmentDraftType) => void;
|
||||
|
@ -41,6 +42,7 @@ function getUrl(attachment: AttachmentDraftType): string | undefined {
|
|||
|
||||
export const AttachmentList = ({
|
||||
attachments,
|
||||
canEditImages,
|
||||
i18n,
|
||||
onAddAttachment,
|
||||
onClickAttachment,
|
||||
|
@ -88,7 +90,7 @@ export const AttachmentList = ({
|
|||
? () => onClickAttachment(attachment)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
const imgElement = (
|
||||
<Image
|
||||
key={key}
|
||||
alt={i18n('stagedImageAttachment', [
|
||||
|
@ -109,6 +111,17 @@ export const AttachmentList = ({
|
|||
onError={closeAttachment}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isImage && canEditImages) {
|
||||
return (
|
||||
<div className="module-attachments--editable">
|
||||
{imgElement}
|
||||
<div className="module-attachments__edit-icon" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return imgElement;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -43,6 +43,8 @@ const searchResultKeys: Array<
|
|||
'conversationResults' | 'contactResults' | 'messageResults'
|
||||
> = ['conversationResults', 'contactResults', 'messageResults'];
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> {
|
||||
private readonly conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
|
||||
|
||||
|
|
|
@ -6,13 +6,17 @@ import classNames from 'classnames';
|
|||
import { get, noop } from 'lodash';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { StickerPicker } from './StickerPicker';
|
||||
import { countStickers } from './lib';
|
||||
|
||||
import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { Theme } from '../../util/theme';
|
||||
import { StickerPicker } from './StickerPicker';
|
||||
import { countStickers } from './lib';
|
||||
import { offsetDistanceModifier } from '../../util/popperUtil';
|
||||
import { themeClassName } from '../../util/theme';
|
||||
|
||||
export type OwnProps = {
|
||||
readonly className?: string;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly receivedPacks: ReadonlyArray<StickerPackType>;
|
||||
readonly installedPacks: ReadonlyArray<StickerPackType>;
|
||||
|
@ -21,19 +25,25 @@ export type OwnProps = {
|
|||
readonly installedPack?: StickerPackType | null;
|
||||
readonly recentStickers: ReadonlyArray<StickerType>;
|
||||
readonly clearInstalledStickerPack: () => unknown;
|
||||
readonly onClickAddPack: () => unknown;
|
||||
readonly onPickSticker: (packId: string, stickerId: number) => unknown;
|
||||
readonly onClickAddPack?: () => unknown;
|
||||
readonly onPickSticker: (
|
||||
packId: string,
|
||||
stickerId: number,
|
||||
url: string
|
||||
) => unknown;
|
||||
readonly showIntroduction?: boolean;
|
||||
readonly clearShowIntroduction: () => unknown;
|
||||
readonly showPickerHint: boolean;
|
||||
readonly clearShowPickerHint: () => unknown;
|
||||
readonly position?: 'top-end' | 'top-start';
|
||||
readonly theme?: Theme;
|
||||
};
|
||||
|
||||
export type Props = OwnProps;
|
||||
|
||||
export const StickerButton = React.memo(
|
||||
({
|
||||
className,
|
||||
i18n,
|
||||
clearInstalledStickerPack,
|
||||
onClickAddPack,
|
||||
|
@ -49,6 +59,7 @@ export const StickerButton = React.memo(
|
|||
showPickerHint,
|
||||
clearShowPickerHint,
|
||||
position = 'top-end',
|
||||
theme,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [popperRoot, setPopperRoot] = React.useState<HTMLElement | null>(
|
||||
|
@ -62,7 +73,7 @@ export const StickerButton = React.memo(
|
|||
|
||||
// Handle button click
|
||||
if (installedPacks.length === 0) {
|
||||
onClickAddPack();
|
||||
onClickAddPack?.();
|
||||
} else if (popperRoot) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
|
@ -78,9 +89,9 @@ export const StickerButton = React.memo(
|
|||
]);
|
||||
|
||||
const handlePickSticker = React.useCallback(
|
||||
(packId: string, stickerId: number) => {
|
||||
(packId: string, stickerId: number, url: string) => {
|
||||
setOpen(false);
|
||||
onPickSticker(packId, stickerId);
|
||||
onPickSticker(packId, stickerId, url);
|
||||
},
|
||||
[setOpen, onPickSticker]
|
||||
);
|
||||
|
@ -94,7 +105,7 @@ export const StickerButton = React.memo(
|
|||
if (showPickerHint) {
|
||||
clearShowPickerHint();
|
||||
}
|
||||
onClickAddPack();
|
||||
onClickAddPack?.();
|
||||
}, [onClickAddPack, showPickerHint, clearShowPickerHint]);
|
||||
|
||||
const handleClearIntroduction = React.useCallback(() => {
|
||||
|
@ -110,13 +121,16 @@ export const StickerButton = React.memo(
|
|||
document.body.appendChild(root);
|
||||
const handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
const targetElement = target as HTMLElement;
|
||||
const className = targetElement ? targetElement.className || '' : '';
|
||||
const targetClassName = targetElement
|
||||
? targetElement.className || ''
|
||||
: '';
|
||||
|
||||
// We need to special-case sticker picker header buttons, because they can
|
||||
// disappear after being clicked, which breaks the .contains() check below.
|
||||
const isMissingButtonClass =
|
||||
!className ||
|
||||
className.indexOf('module-sticker-picker__header__button') < 0;
|
||||
!targetClassName ||
|
||||
targetClassName.indexOf('module-sticker-picker__header__button') <
|
||||
0;
|
||||
|
||||
if (!root.contains(targetElement) && isMissingButtonClass) {
|
||||
setOpen(false);
|
||||
|
@ -194,10 +208,13 @@ export const StickerButton = React.memo(
|
|||
type="button"
|
||||
ref={ref}
|
||||
onClick={handleClickButton}
|
||||
className={classNames({
|
||||
'module-sticker-button__button': true,
|
||||
'module-sticker-button__button--active': open,
|
||||
})}
|
||||
className={classNames(
|
||||
{
|
||||
'module-sticker-button__button': true,
|
||||
'module-sticker-button__button--active': open,
|
||||
},
|
||||
className
|
||||
)}
|
||||
aria-label={i18n('stickers--StickerPicker--Open')}
|
||||
/>
|
||||
)}
|
||||
|
@ -209,84 +226,88 @@ export const StickerButton = React.memo(
|
|||
modifiers={[offsetDistanceModifier(6)]}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps }) => (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="module-sticker-button__tooltip"
|
||||
onClick={clearInstalledStickerPack}
|
||||
>
|
||||
{installedPack.cover ? (
|
||||
<img
|
||||
className="module-sticker-button__tooltip__image"
|
||||
src={installedPack.cover.url}
|
||||
alt={installedPack.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="module-sticker-button__tooltip__image-placeholder" />
|
||||
)}
|
||||
<span className="module-sticker-button__tooltip__text">
|
||||
<span className="module-sticker-button__tooltip__text__title">
|
||||
{installedPack.title}
|
||||
</span>{' '}
|
||||
installed
|
||||
</span>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip__triangle',
|
||||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="module-sticker-button__tooltip"
|
||||
onClick={clearInstalledStickerPack}
|
||||
>
|
||||
{installedPack.cover ? (
|
||||
<img
|
||||
className="module-sticker-button__tooltip__image"
|
||||
src={installedPack.cover.url}
|
||||
alt={installedPack.title}
|
||||
/>
|
||||
) : (
|
||||
<div className="module-sticker-button__tooltip__image-placeholder" />
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="module-sticker-button__tooltip__text">
|
||||
<span className="module-sticker-button__tooltip__text__title">
|
||||
{installedPack.title}
|
||||
</span>{' '}
|
||||
installed
|
||||
</span>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip__triangle',
|
||||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popper>
|
||||
) : null}
|
||||
{!open && showIntroduction ? (
|
||||
<Popper placement={position} modifiers={[offsetDistanceModifier(6)]}>
|
||||
{({ ref, style, placement, arrowProps }) => (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip',
|
||||
'module-sticker-button__tooltip--introduction'
|
||||
)}
|
||||
onClick={handleClearIntroduction}
|
||||
>
|
||||
<img
|
||||
className="module-sticker-button__tooltip--introduction__image"
|
||||
srcSet="images/sticker_splash@1x.png 1x, images/sticker_splash@2x.png 2x"
|
||||
alt={i18n('stickers--StickerManager--Introduction--Image')}
|
||||
/>
|
||||
<div className="module-sticker-button__tooltip--introduction__meta">
|
||||
<div className="module-sticker-button__tooltip--introduction__meta__title">
|
||||
{i18n('stickers--StickerManager--Introduction--Title')}
|
||||
</div>
|
||||
<div className="module-sticker-button__tooltip--introduction__meta__subtitle">
|
||||
{i18n('stickers--StickerManager--Introduction--Body')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-sticker-button__tooltip--introduction__close">
|
||||
<button
|
||||
type="button"
|
||||
className="module-sticker-button__tooltip--introduction__close__button"
|
||||
onClick={handleClearIntroduction}
|
||||
aria-label={i18n('close')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip__triangle',
|
||||
'module-sticker-button__tooltip__triangle--introduction',
|
||||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
'module-sticker-button__tooltip',
|
||||
'module-sticker-button__tooltip--introduction'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
onClick={handleClearIntroduction}
|
||||
>
|
||||
<img
|
||||
className="module-sticker-button__tooltip--introduction__image"
|
||||
srcSet="images/sticker_splash@1x.png 1x, images/sticker_splash@2x.png 2x"
|
||||
alt={i18n('stickers--StickerManager--Introduction--Image')}
|
||||
/>
|
||||
<div className="module-sticker-button__tooltip--introduction__meta">
|
||||
<div className="module-sticker-button__tooltip--introduction__meta__title">
|
||||
{i18n('stickers--StickerManager--Introduction--Title')}
|
||||
</div>
|
||||
<div className="module-sticker-button__tooltip--introduction__meta__subtitle">
|
||||
{i18n('stickers--StickerManager--Introduction--Body')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-sticker-button__tooltip--introduction__close">
|
||||
<button
|
||||
type="button"
|
||||
className="module-sticker-button__tooltip--introduction__close__button"
|
||||
onClick={handleClearIntroduction}
|
||||
aria-label={i18n('close')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip__triangle',
|
||||
'module-sticker-button__tooltip__triangle--introduction',
|
||||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popper>
|
||||
) : null}
|
||||
|
@ -294,17 +315,21 @@ export const StickerButton = React.memo(
|
|||
? createPortal(
|
||||
<Popper placement={position}>
|
||||
{({ ref, style }) => (
|
||||
<StickerPicker
|
||||
ref={ref}
|
||||
i18n={i18n}
|
||||
style={style}
|
||||
packs={installedPacks}
|
||||
onClose={handleClose}
|
||||
onClickAddPack={handleClickAddPack}
|
||||
onPickSticker={handlePickSticker}
|
||||
recentStickers={recentStickers}
|
||||
showPickerHint={showPickerHint}
|
||||
/>
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<StickerPicker
|
||||
ref={ref}
|
||||
i18n={i18n}
|
||||
style={style}
|
||||
packs={installedPacks}
|
||||
onClose={handleClose}
|
||||
onClickAddPack={
|
||||
onClickAddPack ? handleClickAddPack : undefined
|
||||
}
|
||||
onPickSticker={handlePickSticker}
|
||||
recentStickers={recentStickers}
|
||||
showPickerHint={showPickerHint}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popper>,
|
||||
popperRoot
|
||||
|
|
|
@ -12,8 +12,12 @@ import type { LocalizerType } from '../../types/Util';
|
|||
export type OwnProps = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly onClose: () => unknown;
|
||||
readonly onClickAddPack: () => unknown;
|
||||
readonly onPickSticker: (packId: string, stickerId: number) => unknown;
|
||||
readonly onClickAddPack?: () => unknown;
|
||||
readonly onPickSticker: (
|
||||
packId: string,
|
||||
stickerId: number,
|
||||
url: string
|
||||
) => unknown;
|
||||
readonly packs: ReadonlyArray<StickerPackType>;
|
||||
readonly recentStickers: ReadonlyArray<StickerType>;
|
||||
readonly showPickerHint?: boolean;
|
||||
|
@ -230,20 +234,22 @@ export const StickerPicker = React.memo(
|
|||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
ref={addPackRef}
|
||||
className={classNames(
|
||||
'module-sticker-picker__header__button',
|
||||
'module-sticker-picker__header__button--add-pack',
|
||||
{
|
||||
'module-sticker-picker__header__button--hint':
|
||||
showPickerHint,
|
||||
}
|
||||
)}
|
||||
onClick={onClickAddPack}
|
||||
aria-label={i18n('stickers--StickerPicker--AddPack')}
|
||||
/>
|
||||
{onClickAddPack && (
|
||||
<button
|
||||
type="button"
|
||||
ref={addPackRef}
|
||||
className={classNames(
|
||||
'module-sticker-picker__header__button',
|
||||
'module-sticker-picker__header__button--add-pack',
|
||||
{
|
||||
'module-sticker-picker__header__button--hint':
|
||||
showPickerHint,
|
||||
}
|
||||
)}
|
||||
onClick={onClickAddPack}
|
||||
aria-label={i18n('stickers--StickerPicker--AddPack')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames('module-sticker-picker__body', {
|
||||
|
@ -317,7 +323,7 @@ export const StickerPicker = React.memo(
|
|||
ref={maybeFocusRef}
|
||||
key={`${packId}-${id}`}
|
||||
className="module-sticker-picker__body__cell"
|
||||
onClick={() => onPickSticker(packId, id)}
|
||||
onClick={() => onPickSticker(packId, id, url)}
|
||||
>
|
||||
<img
|
||||
className="module-sticker-picker__body__cell__image"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue