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"
|
||||
|
|
21
ts/hooks/usePortal.ts
Normal file
21
ts/hooks/usePortal.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function usePortal(): HTMLDivElement | null {
|
||||
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setRoot(div);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
setRoot(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return root;
|
||||
}
|
9
ts/hooks/useUniqueId.ts
Normal file
9
ts/hooks/useUniqueId.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function useUniqueId(): string {
|
||||
return useMemo(() => uuid(), []);
|
||||
}
|
12
ts/mediaEditor/ImageStateType.ts
Normal file
12
ts/mediaEditor/ImageStateType.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export type ImageStateType = {
|
||||
angle: number;
|
||||
cropX: number;
|
||||
cropY: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
196
ts/mediaEditor/MediaEditorFabricCropRect.ts
Normal file
196
ts/mediaEditor/MediaEditorFabricCropRect.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
import { clamp } from 'lodash';
|
||||
|
||||
export class MediaEditorFabricCropRect extends fabric.Rect {
|
||||
static PADDING = 4;
|
||||
|
||||
constructor(options?: fabric.IRectOptions) {
|
||||
super({
|
||||
fill: undefined,
|
||||
lockScalingFlip: true,
|
||||
...(options || {}),
|
||||
});
|
||||
|
||||
this.on('modified', this.containBounds.bind(this));
|
||||
}
|
||||
|
||||
private containBounds() {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = this.canvas.getZoom() || 1;
|
||||
|
||||
const { left, top, height, width } = this.getBoundingRect();
|
||||
|
||||
const canvasHeight = this.canvas.getHeight();
|
||||
const canvasWidth = this.canvas.getWidth();
|
||||
|
||||
if (height > canvasHeight || width > canvasWidth) {
|
||||
this.canvas.discardActiveObject();
|
||||
} else {
|
||||
this.set(
|
||||
'left',
|
||||
clamp(
|
||||
left / zoom,
|
||||
MediaEditorFabricCropRect.PADDING / zoom,
|
||||
(canvasWidth - width - MediaEditorFabricCropRect.PADDING) / zoom
|
||||
)
|
||||
);
|
||||
this.set(
|
||||
'top',
|
||||
clamp(
|
||||
top / zoom,
|
||||
MediaEditorFabricCropRect.PADDING / zoom,
|
||||
(canvasHeight - height - MediaEditorFabricCropRect.PADDING) / zoom
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.setCoords();
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D): void {
|
||||
super.render(ctx);
|
||||
|
||||
const bounds = this.getBoundingRect();
|
||||
|
||||
const zoom = this.canvas?.getZoom() || 1;
|
||||
const canvasWidth = (this.canvas?.getWidth() || 0) / zoom;
|
||||
const canvasHeight = (this.canvas?.getHeight() || 0) / zoom;
|
||||
const height = bounds.height / zoom;
|
||||
const left = bounds.left / zoom;
|
||||
const top = bounds.top / zoom;
|
||||
const width = bounds.width / zoom;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
// top
|
||||
ctx.fillRect(0, 0, canvasWidth, top);
|
||||
// left
|
||||
ctx.fillRect(0, top, left, height);
|
||||
// bottom
|
||||
ctx.fillRect(0, height + top, canvasWidth, canvasHeight - top);
|
||||
// right
|
||||
ctx.fillRect(left + width, top, canvasWidth - left, height);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
MediaEditorFabricCropRect.prototype.controls = {
|
||||
tl: new fabric.Control({
|
||||
x: -0.5,
|
||||
y: -0.5,
|
||||
actionHandler: fabric.controlsUtils.scalingEqually,
|
||||
render: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
top: number,
|
||||
_,
|
||||
rect: fabric.Object
|
||||
) => {
|
||||
const WIDTH = getMinSize(rect.width);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left - 2, top + WIDTH);
|
||||
ctx.lineTo(left - 2, top - 2);
|
||||
ctx.lineTo(left + WIDTH, top - 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
}),
|
||||
tr: new fabric.Control({
|
||||
x: 0.5,
|
||||
y: -0.5,
|
||||
actionHandler: fabric.controlsUtils.scalingEqually,
|
||||
render: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
top: number,
|
||||
_,
|
||||
rect: fabric.Object
|
||||
) => {
|
||||
const WIDTH = getMinSize(rect.width);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + 2, top + WIDTH);
|
||||
ctx.lineTo(left + 2, top - 2);
|
||||
ctx.lineTo(left - WIDTH, top - 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
}),
|
||||
bl: new fabric.Control({
|
||||
x: -0.5,
|
||||
y: 0.5,
|
||||
actionHandler: fabric.controlsUtils.scalingEqually,
|
||||
render: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
top: number,
|
||||
_,
|
||||
rect: fabric.Object
|
||||
) => {
|
||||
const WIDTH = getMinSize(rect.width);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left - 2, top - WIDTH);
|
||||
ctx.lineTo(left - 2, top + 2);
|
||||
ctx.lineTo(left + WIDTH, top + 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
}),
|
||||
br: new fabric.Control({
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
actionHandler: fabric.controlsUtils.scalingEqually,
|
||||
render: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
top: number,
|
||||
_,
|
||||
rect: fabric.Object
|
||||
) => {
|
||||
const WIDTH = getMinSize(rect.width);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + 2, top - WIDTH);
|
||||
ctx.lineTo(left + 2, top + 2);
|
||||
ctx.lineTo(left - WIDTH, top + 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
MediaEditorFabricCropRect.prototype.excludeFromExport = true;
|
||||
MediaEditorFabricCropRect.prototype.borderColor = '#ffffff';
|
||||
MediaEditorFabricCropRect.prototype.cornerColor = '#ffffff';
|
||||
|
||||
function getMinSize(width: number | undefined): number {
|
||||
return Math.min(width || 24, 24);
|
||||
}
|
35
ts/mediaEditor/MediaEditorFabricIText.ts
Normal file
35
ts/mediaEditor/MediaEditorFabricIText.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
import { customFabricObjectControls } from './util/customFabricObjectControls';
|
||||
|
||||
export class MediaEditorFabricIText extends fabric.IText {
|
||||
constructor(text: string, options: fabric.ITextOptions) {
|
||||
super(text, {
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 'bold',
|
||||
lockScalingFlip: true,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
textAlign: 'center',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
static override fromObject(
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
||||
options: any,
|
||||
callback: (_: MediaEditorFabricIText) => unknown
|
||||
): MediaEditorFabricIText {
|
||||
const result = new MediaEditorFabricIText(options.text, options);
|
||||
callback(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
MediaEditorFabricIText.prototype.type = 'MediaEditorFabricIText';
|
||||
MediaEditorFabricIText.prototype.lockScalingFlip = true;
|
||||
MediaEditorFabricIText.prototype.borderColor = '#ffffff';
|
||||
MediaEditorFabricIText.prototype.controls = customFabricObjectControls;
|
29
ts/mediaEditor/MediaEditorFabricPath.ts
Normal file
29
ts/mediaEditor/MediaEditorFabricPath.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
import { customFabricObjectControls } from './util/customFabricObjectControls';
|
||||
|
||||
export class MediaEditorFabricPath extends fabric.Path {
|
||||
constructor(
|
||||
path?: string | Array<fabric.Point>,
|
||||
options?: fabric.IPathOptions
|
||||
) {
|
||||
super(path, { fill: undefined, lockScalingFlip: true, ...(options || {}) });
|
||||
}
|
||||
|
||||
static override fromObject(
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
||||
options: any,
|
||||
callback: (_: MediaEditorFabricPath) => unknown
|
||||
): MediaEditorFabricPath {
|
||||
const result = new MediaEditorFabricPath(options.path, options);
|
||||
callback(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
MediaEditorFabricPath.prototype.type = 'MediaEditorFabricPath';
|
||||
MediaEditorFabricPath.prototype.borderColor = '#ffffff';
|
||||
MediaEditorFabricPath.prototype.controls = customFabricObjectControls;
|
23
ts/mediaEditor/MediaEditorFabricPencilBrush.ts
Normal file
23
ts/mediaEditor/MediaEditorFabricPencilBrush.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
import { MediaEditorFabricPath } from './MediaEditorFabricPath';
|
||||
|
||||
export class MediaEditorFabricPencilBrush extends fabric.PencilBrush {
|
||||
public strokeMiterLimit: undefined | number;
|
||||
|
||||
override createPath(
|
||||
pathData?: string | Array<fabric.Point>
|
||||
): MediaEditorFabricPath {
|
||||
return new MediaEditorFabricPath(pathData, {
|
||||
fill: undefined,
|
||||
stroke: this.color,
|
||||
strokeWidth: this.width,
|
||||
strokeLineCap: this.strokeLineCap,
|
||||
strokeMiterLimit: this.strokeMiterLimit,
|
||||
strokeLineJoin: this.strokeLineJoin,
|
||||
strokeDashArray: this.strokeDashArray,
|
||||
});
|
||||
}
|
||||
}
|
36
ts/mediaEditor/MediaEditorFabricSticker.ts
Normal file
36
ts/mediaEditor/MediaEditorFabricSticker.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
import { customFabricObjectControls } from './util/customFabricObjectControls';
|
||||
|
||||
export class MediaEditorFabricSticker extends fabric.Image {
|
||||
constructor(
|
||||
element: string | HTMLImageElement | HTMLVideoElement,
|
||||
options: fabric.IImageOptions = {}
|
||||
) {
|
||||
// Fabric seems to have issues when passed a string, but not an Image.
|
||||
let normalizedElement: undefined | HTMLImageElement | HTMLVideoElement;
|
||||
if (typeof element === 'string') {
|
||||
normalizedElement = new Image();
|
||||
normalizedElement.src = element;
|
||||
} else {
|
||||
normalizedElement = element;
|
||||
}
|
||||
|
||||
super(normalizedElement, options);
|
||||
}
|
||||
|
||||
static fromObject(
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
||||
options: any,
|
||||
callback: (_: MediaEditorFabricSticker) => unknown
|
||||
): void {
|
||||
callback(new MediaEditorFabricSticker(options.src, options));
|
||||
}
|
||||
}
|
||||
|
||||
MediaEditorFabricSticker.prototype.type = 'MediaEditorFabricSticker';
|
||||
MediaEditorFabricSticker.prototype.borderColor = '#ffffff';
|
||||
MediaEditorFabricSticker.prototype.controls = customFabricObjectControls;
|
152
ts/mediaEditor/useFabricHistory.ts
Normal file
152
ts/mediaEditor/useFabricHistory.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import type { ImageStateType } from './ImageStateType';
|
||||
import { MediaEditorFabricIText } from './MediaEditorFabricIText';
|
||||
import { MediaEditorFabricPath } from './MediaEditorFabricPath';
|
||||
import { MediaEditorFabricSticker } from './MediaEditorFabricSticker';
|
||||
|
||||
export function useFabricHistory(
|
||||
canvas: fabric.Canvas | undefined
|
||||
): FabricHistory | undefined {
|
||||
const [history, setHistory] = useState<FabricHistory | undefined>();
|
||||
|
||||
// We need this type of precision so that when serializing/deserializing
|
||||
// the floats don't get rounded off and we maintain proper image state.
|
||||
// http://fabricjs.com/fabric-gotchas
|
||||
fabric.Object.NUM_FRACTION_DIGITS = 16;
|
||||
|
||||
// Attach our custom classes to the global Fabric instance. Unfortunately, Fabric
|
||||
// doesn't make it easy to deserialize into a custom class without polluting the
|
||||
// global namespace. See <http://fabricjs.com/fabric-intro-part-3#subclassing>.
|
||||
Object.assign(fabric, {
|
||||
MediaEditorFabricIText,
|
||||
MediaEditorFabricPath,
|
||||
MediaEditorFabricSticker,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
const fabricHistory = new FabricHistory(canvas);
|
||||
setHistory(fabricHistory);
|
||||
}
|
||||
}, [canvas]);
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
const LIMIT = 1000;
|
||||
|
||||
type SnapshotStateType = {
|
||||
canvasState: string;
|
||||
imageState?: ImageStateType;
|
||||
};
|
||||
|
||||
export class FabricHistory extends EventEmitter {
|
||||
private readonly canvas: fabric.Canvas;
|
||||
|
||||
private highWatermark: number;
|
||||
private isTimeTraveling: boolean;
|
||||
private snapshots: Array<SnapshotStateType>;
|
||||
|
||||
constructor(canvas: fabric.Canvas) {
|
||||
super();
|
||||
|
||||
this.canvas = canvas;
|
||||
this.highWatermark = 0;
|
||||
this.isTimeTraveling = false;
|
||||
this.snapshots = [];
|
||||
|
||||
this.canvas.on('object:added', this.onObjectModified.bind(this));
|
||||
this.canvas.on('object:modified', this.onObjectModified.bind(this));
|
||||
this.canvas.on('object:removed', this.onObjectModified.bind(this));
|
||||
}
|
||||
|
||||
private applyState({ canvasState, imageState }: SnapshotStateType): void {
|
||||
this.canvas.loadFromJSON(canvasState, () => {
|
||||
this.emit('appliedState', imageState);
|
||||
this.emit('historyChanged');
|
||||
this.isTimeTraveling = false;
|
||||
});
|
||||
}
|
||||
|
||||
private getState(): string {
|
||||
return JSON.stringify(this.canvas.toDatalessJSON());
|
||||
}
|
||||
|
||||
private onObjectModified({ target }: fabric.IEvent): void {
|
||||
if (target?.excludeFromExport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.takeSnapshot();
|
||||
}
|
||||
|
||||
private getUndoState(): SnapshotStateType | undefined {
|
||||
if (!this.canUndo()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.highWatermark -= 1;
|
||||
return this.snapshots[this.highWatermark];
|
||||
}
|
||||
|
||||
private getRedoState(): SnapshotStateType | undefined {
|
||||
if (this.canRedo()) {
|
||||
this.highWatermark += 1;
|
||||
}
|
||||
|
||||
return this.snapshots[this.highWatermark];
|
||||
}
|
||||
|
||||
public takeSnapshot(imageState?: ImageStateType): void {
|
||||
if (this.isTimeTraveling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.canRedo()) {
|
||||
this.snapshots.splice(this.highWatermark, this.snapshots.length);
|
||||
}
|
||||
|
||||
this.snapshots.push({ canvasState: this.getState(), imageState });
|
||||
if (this.snapshots.length > LIMIT) {
|
||||
this.snapshots.shift();
|
||||
}
|
||||
this.highWatermark = this.snapshots.length - 1;
|
||||
this.emit('historyChanged');
|
||||
}
|
||||
|
||||
public undo(): void {
|
||||
const undoState = this.getUndoState();
|
||||
|
||||
if (!undoState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isTimeTraveling = true;
|
||||
this.applyState(undoState);
|
||||
}
|
||||
|
||||
public redo(): void {
|
||||
const redoState = this.getRedoState();
|
||||
|
||||
if (!redoState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isTimeTraveling = true;
|
||||
this.applyState(redoState);
|
||||
}
|
||||
|
||||
public canUndo(): boolean {
|
||||
return this.highWatermark > 0;
|
||||
}
|
||||
|
||||
public canRedo(): boolean {
|
||||
return this.highWatermark < this.snapshots.length - 1;
|
||||
}
|
||||
}
|
47
ts/mediaEditor/util/color.ts
Normal file
47
ts/mediaEditor/util/color.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
function getRatio(min: number, max: number, value: number) {
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
function getHSLValues(percentage: number): [number, number, number] {
|
||||
if (percentage <= 10) {
|
||||
return [0, 0, 1 - getRatio(0, 10, percentage)];
|
||||
}
|
||||
|
||||
if (percentage < 20) {
|
||||
return [0, 0.5, 0.5 * getRatio(10, 20, percentage)];
|
||||
}
|
||||
|
||||
const ratio = getRatio(20, 100, percentage);
|
||||
|
||||
return [360 * ratio, 1, 0.5];
|
||||
}
|
||||
|
||||
export function getHSL(percentage: number): string {
|
||||
const [h, s, l] = getHSLValues(percentage);
|
||||
return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative
|
||||
export function getRGBA(percentage: number, alpha = 1): string {
|
||||
const [h, s, l] = getHSLValues(percentage);
|
||||
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
|
||||
function f(n: number): number {
|
||||
const k = (n + h / 30) % 12;
|
||||
return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
}
|
||||
|
||||
const rgbValue = [
|
||||
Math.round(255 * f(0)),
|
||||
Math.round(255 * f(8)),
|
||||
Math.round(255 * f(4)),
|
||||
]
|
||||
.map(String)
|
||||
.join(',');
|
||||
|
||||
return `rgba(${rgbValue},${alpha})`;
|
||||
}
|
134
ts/mediaEditor/util/customFabricObjectControls.ts
Normal file
134
ts/mediaEditor/util/customFabricObjectControls.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { fabric } from 'fabric';
|
||||
|
||||
const resizeControl = new fabric.Control({
|
||||
actionHandler: fabric.controlsUtils.scalingEqually,
|
||||
cursorStyleHandler: () => 'se-resize',
|
||||
render: (ctx: CanvasRenderingContext2D, left: number, top: number) => {
|
||||
// circle
|
||||
const size = 9;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(left, top, size, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
|
||||
// arrows NW & SE
|
||||
const arrowSize = 4;
|
||||
ctx.fillStyle = '#3b3b3b';
|
||||
ctx.strokeStyle = '#3b3b3b';
|
||||
ctx.beginPath();
|
||||
|
||||
// SE
|
||||
ctx.moveTo(left + 0.5, top + 0.5);
|
||||
ctx.lineTo(left + arrowSize, top + arrowSize);
|
||||
ctx.moveTo(left + arrowSize, top + 1);
|
||||
ctx.lineTo(left + arrowSize, top + arrowSize);
|
||||
ctx.lineTo(left + 1, top + arrowSize);
|
||||
|
||||
// NW
|
||||
ctx.moveTo(left - 0.5, top - 0.5);
|
||||
ctx.lineTo(left - arrowSize, top - arrowSize);
|
||||
ctx.moveTo(left - arrowSize, top - 1);
|
||||
ctx.lineTo(left - arrowSize, top - arrowSize);
|
||||
ctx.lineTo(left - 1, top - arrowSize);
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
});
|
||||
|
||||
const rotateControl = new fabric.Control({
|
||||
actionHandler: fabric.controlsUtils.rotationWithSnapping,
|
||||
actionName: 'rotate',
|
||||
cursorStyleHandler: fabric.controlsUtils.rotationStyleHandler,
|
||||
offsetY: -40,
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
top: number,
|
||||
_,
|
||||
target: fabric.Object
|
||||
) {
|
||||
const size = 5;
|
||||
ctx.save();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
|
||||
// connecting line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left, top);
|
||||
const radians = 0 - ((target.angle || 0) * Math.PI) / 180;
|
||||
const targetLeft = 40 * Math.sin(radians);
|
||||
const targetTop = 40 * Math.cos(radians);
|
||||
ctx.lineTo(left + targetLeft, top + targetTop);
|
||||
ctx.stroke();
|
||||
|
||||
// circle
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left, top);
|
||||
ctx.arc(left, top, size, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
withConnection: false,
|
||||
x: 0,
|
||||
y: -0.5,
|
||||
});
|
||||
|
||||
const deleteControl = new fabric.Control({
|
||||
cursorStyleHandler: () => 'pointer',
|
||||
// This is lifted from <http://fabricjs.com/custom-control-render>.
|
||||
mouseUpHandler: (_eventData, { target }) => {
|
||||
if (!target.canvas) {
|
||||
return false;
|
||||
}
|
||||
target.canvas.remove(target);
|
||||
return true;
|
||||
},
|
||||
render: (ctx: CanvasRenderingContext2D, left: number, top: number) => {
|
||||
// circle
|
||||
const size = 9;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(left, top, size, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
|
||||
// x
|
||||
const xSize = 3;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
const topLeft = new fabric.Point(left - xSize, top - xSize);
|
||||
const topRight = new fabric.Point(left + xSize, top - xSize);
|
||||
const bottomRight = new fabric.Point(left + xSize, top + xSize);
|
||||
const bottomLeft = new fabric.Point(left - xSize, top + xSize);
|
||||
|
||||
ctx.moveTo(topLeft.x, topLeft.y);
|
||||
ctx.lineTo(bottomRight.x, bottomRight.y);
|
||||
ctx.moveTo(topRight.x, topRight.y);
|
||||
ctx.lineTo(bottomLeft.x, bottomLeft.y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
x: -0.5,
|
||||
y: -0.5,
|
||||
});
|
||||
|
||||
export const customFabricObjectControls = {
|
||||
br: resizeControl,
|
||||
mtr: rotateControl,
|
||||
tl: deleteControl,
|
||||
};
|
44
ts/mediaEditor/util/getTextStyleAttributes.ts
Normal file
44
ts/mediaEditor/util/getTextStyleAttributes.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import { getHSL } from './color';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
export enum TextStyle {
|
||||
Regular = 'Regular',
|
||||
Highlight = 'Highlight',
|
||||
Outline = 'Outline',
|
||||
}
|
||||
|
||||
export function getTextStyleAttributes(
|
||||
textStyle: TextStyle,
|
||||
hueSliderValue: number
|
||||
): {
|
||||
fill: string;
|
||||
stroke?: string;
|
||||
strokeWidth: number;
|
||||
textBackgroundColor: string;
|
||||
} {
|
||||
const color = getHSL(hueSliderValue);
|
||||
switch (textStyle) {
|
||||
case TextStyle.Regular:
|
||||
return { fill: color, strokeWidth: 0, textBackgroundColor: '' };
|
||||
case TextStyle.Highlight:
|
||||
return {
|
||||
fill: hueSliderValue <= 5 ? '#000' : '#fff',
|
||||
strokeWidth: 0,
|
||||
textBackgroundColor: color,
|
||||
};
|
||||
case TextStyle.Outline:
|
||||
return {
|
||||
fill: hueSliderValue <= 5 ? '#000' : '#fff',
|
||||
stroke: color,
|
||||
strokeWidth: 2,
|
||||
textBackgroundColor: '',
|
||||
};
|
||||
default:
|
||||
log.error(missingCaseError(textStyle));
|
||||
return getTextStyleAttributes(TextStyle.Regular, hueSliderValue);
|
||||
}
|
||||
}
|
|
@ -123,9 +123,10 @@ function addAttachment(
|
|||
? getState().composer.attachments
|
||||
: getAttachmentsFromConversationModel(conversationId);
|
||||
|
||||
// We expect there to either be a pending draft attachment or an existing
|
||||
// attachment that we'll be replacing.
|
||||
const hasDraftAttachmentPending = draftAttachments.some(
|
||||
draftAttachment =>
|
||||
draftAttachment.pending && draftAttachment.path === attachment.path
|
||||
draftAttachment => draftAttachment.path === attachment.path
|
||||
);
|
||||
|
||||
// User has canceled the draft so we don't need to continue processing
|
||||
|
|
96
ts/test-both/helpers/getStickerPacks.ts
Normal file
96
ts/test-both/helpers/getStickerPacks.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
|
||||
|
||||
export const createPack = (
|
||||
props: Partial<StickerPackType>,
|
||||
sticker?: StickerType
|
||||
): StickerPackType => ({
|
||||
id: '',
|
||||
title: props.id ? `${props.id} title` : 'title',
|
||||
key: '',
|
||||
author: '',
|
||||
isBlessed: false,
|
||||
lastUsed: 0,
|
||||
status: 'known',
|
||||
cover: sticker,
|
||||
stickerCount: 101,
|
||||
stickers: sticker
|
||||
? Array(101)
|
||||
.fill(0)
|
||||
.map((_, id) => ({ ...sticker, id }))
|
||||
: [],
|
||||
...props,
|
||||
});
|
||||
|
||||
export const Stickers: Record<string, StickerType> = {
|
||||
kitten1: {
|
||||
id: 1,
|
||||
url: '/fixtures/kitten-1-64-64.jpg',
|
||||
packId: 'kitten1',
|
||||
emoji: '',
|
||||
},
|
||||
|
||||
kitten2: {
|
||||
id: 2,
|
||||
url: '/fixtures/kitten-2-64-64.jpg',
|
||||
packId: 'kitten2',
|
||||
emoji: '',
|
||||
},
|
||||
|
||||
kitten3: {
|
||||
id: 3,
|
||||
url: '/fixtures/kitten-3-64-64.jpg',
|
||||
packId: 'kitten3',
|
||||
emoji: '',
|
||||
},
|
||||
|
||||
abe: {
|
||||
id: 4,
|
||||
url: '/fixtures/512x515-thumbs-up-lincoln.webp',
|
||||
packId: 'abe',
|
||||
emoji: '',
|
||||
},
|
||||
|
||||
wide: {
|
||||
id: 5,
|
||||
url: '/fixtures/1000x50-green.jpeg',
|
||||
packId: 'wide',
|
||||
emoji: '',
|
||||
},
|
||||
|
||||
tall: {
|
||||
id: 6,
|
||||
url: '/fixtures/50x1000-teal.jpeg',
|
||||
packId: 'tall',
|
||||
emoji: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const receivedPacks = [
|
||||
createPack({ id: 'abe', status: 'downloaded' }, Stickers.abe),
|
||||
createPack({ id: 'kitten3', status: 'downloaded' }, Stickers.kitten3),
|
||||
];
|
||||
|
||||
export const installedPacks = [
|
||||
createPack({ id: 'kitten1', status: 'installed' }, Stickers.kitten1),
|
||||
createPack({ id: 'kitten2', status: 'installed' }, Stickers.kitten2),
|
||||
createPack({ id: 'kitten3', status: 'installed' }, Stickers.kitten3),
|
||||
];
|
||||
|
||||
export const blessedPacks = [
|
||||
createPack(
|
||||
{ id: 'wide', status: 'downloaded', isBlessed: true },
|
||||
Stickers.wide
|
||||
),
|
||||
createPack(
|
||||
{ id: 'tall', status: 'downloaded', isBlessed: true },
|
||||
Stickers.tall
|
||||
),
|
||||
];
|
||||
|
||||
export const knownPacks = [
|
||||
createPack({ id: 'kitten1', status: 'known' }, Stickers.kitten1),
|
||||
createPack({ id: 'kitten2', status: 'known' }, Stickers.kitten2),
|
||||
];
|
17
ts/util/canEditImages.ts
Normal file
17
ts/util/canEditImages.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
import { getEnvironment, Environment } from '../environment';
|
||||
import { isBeta } from './version';
|
||||
|
||||
export function canEditImages(): boolean {
|
||||
return (
|
||||
isEnabled('desktop.internalUser') ||
|
||||
getEnvironment() === Environment.Staging ||
|
||||
getEnvironment() === Environment.Development ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Boolean((window as any).STORYBOOK_ENV) ||
|
||||
isBeta(window.getVersion())
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue