Add image editor

This commit is contained in:
Josh Perez 2021-11-30 21:14:25 -05:00 committed by GitHub
parent 86d09917a3
commit 7affe313f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 4261 additions and 173 deletions

View file

@ -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;
}
);

View file

@ -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) {

View 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()} />;
});

View 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>
);
};

View file

@ -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: '',
},
})}
/>

View 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} />
));

View 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
);
};

View file

@ -43,7 +43,7 @@ story.add('One File', () => {
}),
],
});
return <AttachmentList {...props} />;
return <AttachmentList {...props} canEditImages />;
});
story.add('Multiple Visual Attachments', () => {

View file

@ -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 (

View file

@ -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>;

View file

@ -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

View file

@ -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
View 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
View 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(), []);
}

View 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;
};

View 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);
}

View 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;

View 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;

View 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,
});
}
}

View 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;

View 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;
}
}

View 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})`;
}

View 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,
};

View 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);
}
}

View file

@ -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

View 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
View 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