signal-desktop/ts/components/MediaEditor.tsx
2024-07-23 17:31:40 -07:00

1608 lines
49 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { fabric } from 'fabric';
import { useSelector } from 'react-redux';
import { get, has, noop } from 'lodash';
import type {
EmojiPickDataType,
Props as EmojiPickerProps,
} from './emoji/EmojiPicker';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { ImageStateType } from '../mediaEditor/ImageStateType';
import type {
InputApi,
Props as CompositionInputProps,
} from './CompositionInput';
import type { LocalizerType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
import type { Props as StickerButtonProps } from './stickers/StickerButton';
import type { imageToBlurHash } from '../util/imageToBlurHash';
import { MediaEditorFabricAnalogTimeSticker } from '../mediaEditor/MediaEditorFabricAnalogTimeSticker';
import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect';
import { MediaEditorFabricDigitalTimeSticker } from '../mediaEditor/MediaEditorFabricDigitalTimeSticker';
import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText';
import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush';
import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker';
import { fabricEffectListener } from '../mediaEditor/fabricEffectListener';
import { getRGBA, getHSL } from '../mediaEditor/util/color';
import {
getTextStyleAttributes,
TextStyle,
} from '../mediaEditor/util/getTextStyleAttributes';
import * as log from '../logging/log';
import { Button, ButtonVariant } from './Button';
import { CompositionInput } from './CompositionInput';
import { ContextMenu } from './ContextMenu';
import { EmojiButton } from './emoji/EmojiButton';
import { IMAGE_PNG } from '../types/MIME';
import { SizeObserver } from '../hooks/useSizeObserver';
import { Slider } from './Slider';
import { Spinner } from './Spinner';
import { StickerButton } from './stickers/StickerButton';
import { Theme } from '../util/theme';
import { ThemeType } from '../types/Util';
import { arrow } from '../util/keyboard';
import { canvasToBytes } from '../util/canvasToBytes';
import { getConversationSelector } from '../state/selectors/conversations';
import { hydrateRanges } from '../types/BodyRange';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { useFabricHistory } from '../mediaEditor/useFabricHistory';
import { usePortal } from '../hooks/usePortal';
import { useUniqueId } from '../hooks/useUniqueId';
export type MediaEditorResultType = Readonly<{
data: Uint8Array;
contentType: MIMEType;
blurHash: string;
caption?: string;
captionBodyRanges?: DraftBodyRanges;
}>;
export type PropsType = {
doneButtonLabel?: string;
i18n: LocalizerType;
imageSrc: string;
isSending: boolean;
imageToBlurHash: typeof imageToBlurHash;
onClose: () => unknown;
onDone: (result: MediaEditorResultType) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
Pick<
CompositionInputProps,
| 'draftText'
| 'draftBodyRanges'
| 'getPreferredBadge'
| 'isFormattingEnabled'
| 'onPickEmoji'
| 'onTextTooLong'
| 'platform'
| 'sortedGroupMembers'
> &
Omit<EmojiPickerProps, 'wasInvokedFromKeyboard'>;
const INITIAL_IMAGE_STATE: ImageStateType = {
angle: 0,
cropX: 0,
cropY: 0,
flipX: false,
flipY: false,
height: 0,
width: 0,
};
enum EditMode {
Crop = 'Crop',
Draw = 'Draw',
Text = 'Text',
}
enum DrawWidth {
Thin = 2,
Regular = 4,
Medium = 12,
Heavy = 24,
}
enum DrawTool {
Pen = 'Pen',
Highlighter = 'Highlighter',
}
enum CropPreset {
Freeform = 'Freeform',
Square = 'Square',
Vertical = 'Vertical',
}
type PendingCropType = {
left: number;
top: number;
width: number;
height: number;
};
function isCmdOrCtrl(ev: KeyboardEvent): boolean {
const { ctrlKey, metaKey } = ev;
const commandKey = get(window, 'platform') === 'darwin' && metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
return commandKey || controlKey;
}
export function MediaEditor({
doneButtonLabel,
i18n,
imageSrc,
isSending,
onClose,
onDone,
// CompositionInput
draftText,
draftBodyRanges,
getPreferredBadge,
isFormattingEnabled,
onPickEmoji,
onTextTooLong,
platform,
sortedGroupMembers,
// EmojiPickerProps
onSetSkinTone,
recentEmojis,
skinTone,
// StickerButtonProps
installedPacks,
recentStickers,
...props
}: PropsType): JSX.Element | null {
const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
const [image, setImage] = useState<HTMLImageElement>(new Image());
const [isStickerPopperOpen, setIsStickerPopperOpen] =
useState<boolean>(false);
const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false);
const [caption, setCaption] = useState(draftText ?? '');
const [captionBodyRanges, setCaptionBodyRanges] =
useState<DraftBodyRanges | null>(draftBodyRanges);
const conversationSelector = useSelector(getConversationSelector);
const hydratedBodyRanges = useMemo(
() => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector),
[captionBodyRanges, conversationSelector]
);
const inputApiRef = useRef<InputApi | undefined>();
const closeEmojiPickerAndFocusComposer = useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
setEmojiPopperOpen(false);
}, [inputApiRef]);
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onPickEmoji(e);
}
},
[inputApiRef, onPickEmoji]
);
const canvasId = useUniqueId();
const [imageState, setImageState] =
useState<ImageStateType>(INITIAL_IMAGE_STATE);
const [cropPreset, setCropPreset] = useState<CropPreset>(CropPreset.Freeform);
// History state
const { canRedo, canUndo, redoIfPossible, takeSnapshot, undoIfPossible } =
useFabricHistory({
fabricCanvas,
imageState,
setImageState,
});
// Initial image load and Fabric canvas setup
useEffect(() => {
// This is important. We can't re-run this function if we've already setup
// a canvas since Fabric doesn't like that.
if (fabricCanvas) {
return;
}
const img = new Image();
img.onload = () => {
setImage(img);
const canvas = new fabric.Canvas(canvasId);
canvas.selection = false;
setFabricCanvas(canvas);
const newImageState = {
...INITIAL_IMAGE_STATE,
height: img.height,
width: img.width,
};
setImageState(newImageState);
takeSnapshot('initial state', newImageState, canvas);
};
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, fabricCanvas, imageSrc, onClose, takeSnapshot]);
const [editMode, setEditMode] = useState<EditMode | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const onTryClose = useCallback(() => {
confirmDiscardIf(canUndo, onClose);
}, [confirmDiscardIf, canUndo, onClose]);
// Keyboard support
useEffect(() => {
if (!fabricCanvas) {
return noop;
}
const globalShortcuts: Array<
[(ev: KeyboardEvent) => boolean, () => unknown]
> = [
[
ev => isCmdOrCtrl(ev) && ev.key === 'c',
() => setEditMode(EditMode.Crop),
],
[
ev => isCmdOrCtrl(ev) && ev.key === 'd',
() => setEditMode(EditMode.Draw),
],
[
ev => isCmdOrCtrl(ev) && ev.key === 't',
() => setEditMode(EditMode.Text),
],
[ev => isCmdOrCtrl(ev) && ev.key === 'z', undoIfPossible],
[ev => isCmdOrCtrl(ev) && ev.shiftKey && ev.key === 'z', redoIfPossible],
[
ev => ev.key === 'Escape',
() => {
// if the emoji popper is open,
// it will use the escape key to close itself
if (isEmojiPopperOpen) {
return;
}
// close window if the user is not in the middle of something
if (editMode === undefined) {
// if the stickers popper is open,
// it will use the escape key to close itself
//
// there's no easy way to prevent an ESC meant for the
// sticker-picker from hitting this handler first
if (!isStickerPopperOpen) {
onTryClose();
}
} else {
setEditMode(undefined);
}
if (fabricCanvas.getActiveObject()) {
fabricCanvas.discardActiveObject();
fabricCanvas.requestRenderAll();
}
},
],
];
const objectShortcuts: Array<
[
(ev: KeyboardEvent) => boolean,
(obj: fabric.Object, ev: KeyboardEvent) => unknown,
]
> = [
[
ev => ev.key === 'Delete',
obj => {
fabricCanvas.remove(obj);
setEditMode(undefined);
},
],
[
ev => ev.key === 'ArrowUp',
(obj, ev) => {
const px = ev.shiftKey ? 20 : 1;
if (ev.altKey) {
obj.set('angle', (obj.angle || 0) - px);
} else {
const { x, y } = obj.getCenterPoint();
obj.setPositionByOrigin(
new fabric.Point(x, y - px),
'center',
'center'
);
}
obj.setCoords();
fabricCanvas.requestRenderAll();
},
],
[
ev => ev.key === arrow('start'),
(obj, ev) => {
const px = ev.shiftKey ? 20 : 1;
if (ev.altKey) {
obj.set('angle', (obj.angle || 0) - px);
} else {
const { x, y } = obj.getCenterPoint();
obj.setPositionByOrigin(
new fabric.Point(x - px, y),
'center',
'center'
);
}
obj.setCoords();
fabricCanvas.requestRenderAll();
},
],
[
ev => ev.key === 'ArrowDown',
(obj, ev) => {
const px = ev.shiftKey ? 20 : 1;
if (ev.altKey) {
obj.set('angle', (obj.angle || 0) + px);
} else {
const { x, y } = obj.getCenterPoint();
obj.setPositionByOrigin(
new fabric.Point(x, y + px),
'center',
'center'
);
}
obj.setCoords();
fabricCanvas.requestRenderAll();
},
],
[
ev => ev.key === arrow('end'),
(obj, ev) => {
const px = ev.shiftKey ? 20 : 1;
if (ev.altKey) {
obj.set('angle', (obj.angle || 0) + px);
} else {
const { x, y } = obj.getCenterPoint();
obj.setPositionByOrigin(
new fabric.Point(x + px, y),
'center',
'center'
);
}
obj.setCoords();
fabricCanvas.requestRenderAll();
},
],
];
function handleKeydown(ev: KeyboardEvent) {
if (!fabricCanvas) {
return;
}
globalShortcuts.forEach(([conditional, runShortcut]) => {
if (conditional(ev)) {
runShortcut();
ev.preventDefault();
ev.stopPropagation();
}
});
const obj = fabricCanvas.getActiveObject();
if (
!obj ||
obj.excludeFromExport ||
(obj instanceof MediaEditorFabricIText && obj.isEditing)
) {
return;
}
objectShortcuts.forEach(([conditional, runShortcut]) => {
if (conditional(ev)) {
runShortcut(obj, ev);
ev.preventDefault();
ev.stopPropagation();
}
});
}
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [
fabricCanvas,
editMode,
isEmojiPopperOpen,
isStickerPopperOpen,
onTryClose,
redoIfPossible,
undoIfPossible,
]);
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(() => {
if (!fabricCanvas) {
return;
}
drawFabricBackgroundImage({ fabricCanvas, image, imageState });
}, [fabricCanvas, image, imageState]);
const [canCrop, setCanCrop] = useState(false);
const [cropAspectRatioLock, setCropAspectRatioLock] = useState(false);
const [drawTool, setDrawTool] = useState<DrawTool>(DrawTool.Pen);
const [drawWidth, setDrawWidth] = useState<DrawWidth>(DrawWidth.Regular);
const [sliderValue, setSliderValue] = useState<number>(0);
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
// If you select a text path auto enter edit mode
useEffect(() => {
if (!fabricCanvas) {
return;
}
return fabricEffectListener(
fabricCanvas,
['selection:created', 'selection:updated', 'selection:cleared'],
() => {
if (fabricCanvas?.getActiveObject() instanceof MediaEditorFabricIText) {
setEditMode(EditMode.Text);
} else if (editMode === EditMode.Text) {
setEditMode(undefined);
}
}
);
}, [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;
}
const obj = fabricCanvas.getActiveObject();
if (!obj || !(obj instanceof MediaEditorFabricIText)) {
return;
}
const { isEditing } = obj;
obj.exitEditing();
obj.set(getTextStyleAttributes(textStyle, sliderValue));
fabricCanvas.requestRenderAll();
if (isEditing) {
obj.enterEditing();
}
}, [fabricCanvas, sliderValue, textStyle]);
useEffect(() => {
if (!fabricCanvas) {
return;
}
const rect = fabricCanvas.getObjects().find(obj => {
return obj instanceof MediaEditorFabricCropRect;
});
if (!rect) {
return;
}
const PADDING = MediaEditorFabricCropRect.PADDING / zoom;
let height =
imageState.height - PADDING * Math.max(440 / imageState.height, 2);
let width =
imageState.width - PADDING * Math.max(440 / imageState.width, 2);
if (cropPreset === CropPreset.Square) {
const size = Math.min(height, width);
height = size;
width = size;
} else if (cropPreset === CropPreset.Vertical) {
width = height * 0.5625;
}
rect.set({ height, width, scaleX: 1, scaleY: 1 });
fabricCanvas.viewportCenterObject(rect);
rect.setCoords();
setCanCrop(true);
}, [cropPreset, fabricCanvas, imageState.height, imageState.width, zoom]);
// 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('modified', () => {
const { height: currHeight, width: currWidth } =
rect.getBoundingRect(true);
setCanCrop(currHeight < height || currWidth < 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);
}
});
}
setCanCrop(false);
}, [editMode, fabricCanvas, imageState.height, imageState.width, zoom]);
// Create an IText node when edit mode changes to Text
useEffect(() => {
if (!fabricCanvas) {
return;
}
if (editMode !== EditMode.Text) {
return;
}
const obj = fabricCanvas.getActiveObject();
if (obj instanceof MediaEditorFabricIText) {
return;
}
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();
}, [
editMode,
fabricCanvas,
imageState.height,
imageState.width,
sliderValue,
textStyle,
]);
const [isSaving, setIsSaving] = useState(false);
// 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 toolElement: JSX.Element | undefined;
if (editMode === EditMode.Text) {
toolElement = (
<>
<div className="MediaEditor__tools-row-1" />
<div className="MediaEditor__tools-row-2">
<div className="MediaEditor__toolbar">
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('icu:CustomColorEditor__hue')}
moduleClassName="HueSlider MediaEditor__toolbar__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'MediaEditor__icon--text-regular',
label: i18n('icu:MediaEditor__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'MediaEditor__icon--text-highlight',
label: i18n('icu:MediaEditor__text--highlight'),
onClick: () => setTextStyle(TextStyle.Highlight),
value: TextStyle.Highlight,
},
{
icon: 'MediaEditor__icon--text-outline',
label: i18n('icu:MediaEditor__text--outline'),
onClick: () => setTextStyle(TextStyle.Outline),
value: TextStyle.Outline,
},
]}
moduleClassName={classNames('MediaEditor__toolbar__tool', {
'MediaEditor__toolbar__button--text-regular':
textStyle === TextStyle.Regular,
'MediaEditor__toolbar__button--text-highlight':
textStyle === TextStyle.Highlight,
'MediaEditor__toolbar__button--text-outline':
textStyle === TextStyle.Outline,
})}
theme={Theme.Dark}
value={textStyle}
/>
</div>
<Button
onClick={() => {
setEditMode(undefined);
const activeObject = fabricCanvas?.getActiveObject();
if (activeObject instanceof MediaEditorFabricIText) {
activeObject.exitEditing();
}
}}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('icu:done')}
</Button>
</div>
</>
);
} else if (editMode === EditMode.Draw) {
toolElement = (
<>
<div className="MediaEditor__tools-row-1" />
<div className="MediaEditor__tools-row-2">
<div className="MediaEditor__toolbar">
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('icu:CustomColorEditor__hue')}
moduleClassName="HueSlider MediaEditor__toolbar__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'MediaEditor__icon--draw-pen',
label: i18n('icu:MediaEditor__draw--pen'),
onClick: () => setDrawTool(DrawTool.Pen),
value: DrawTool.Pen,
},
{
icon: 'MediaEditor__icon--draw-highlighter',
label: i18n('icu:MediaEditor__draw--highlighter'),
onClick: () => setDrawTool(DrawTool.Highlighter),
value: DrawTool.Highlighter,
},
]}
moduleClassName={classNames('MediaEditor__toolbar__tool', {
'MediaEditor__toolbar__button--draw-pen':
drawTool === DrawTool.Pen,
'MediaEditor__toolbar__button--draw-highlighter':
drawTool === DrawTool.Highlighter,
})}
theme={Theme.Dark}
value={drawTool}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'MediaEditor__icon--width-thin',
label: i18n('icu:MediaEditor__draw--thin'),
onClick: () => setDrawWidth(DrawWidth.Thin),
value: DrawWidth.Thin,
},
{
icon: 'MediaEditor__icon--width-regular',
label: i18n('icu:MediaEditor__draw--regular'),
onClick: () => setDrawWidth(DrawWidth.Regular),
value: DrawWidth.Regular,
},
{
icon: 'MediaEditor__icon--width-medium',
label: i18n('icu:MediaEditor__draw--medium'),
onClick: () => setDrawWidth(DrawWidth.Medium),
value: DrawWidth.Medium,
},
{
icon: 'MediaEditor__icon--width-heavy',
label: i18n('icu:MediaEditor__draw--heavy'),
onClick: () => setDrawWidth(DrawWidth.Heavy),
value: DrawWidth.Heavy,
},
]}
moduleClassName={classNames('MediaEditor__toolbar__tool', {
'MediaEditor__toolbar__button--width-thin':
drawWidth === DrawWidth.Thin,
'MediaEditor__toolbar__button--width-regular':
drawWidth === DrawWidth.Regular,
'MediaEditor__toolbar__button--width-medium':
drawWidth === DrawWidth.Medium,
'MediaEditor__toolbar__button--width-heavy':
drawWidth === DrawWidth.Heavy,
})}
theme={Theme.Dark}
value={drawWidth}
/>
</div>
<Button
onClick={() => setEditMode(undefined)}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('icu:done')}
</Button>
</div>
</>
);
} else if (editMode === EditMode.Crop) {
const canReset =
imageState.cropX !== 0 ||
imageState.cropY !== 0 ||
imageState.flipX ||
imageState.flipY ||
imageState.angle !== 0;
toolElement = (
<>
<div className="MediaEditor__tools-row-1">
<button
className={classNames(
'MediaEditor__crop-preset MediaEditor__crop-preset--free',
{
'MediaEditor__crop-preset--selected':
cropPreset === CropPreset.Freeform,
}
)}
onClick={() => setCropPreset(CropPreset.Freeform)}
type="button"
>
{i18n('icu:MediaEditor__crop-preset--freeform')}
</button>
<button
className={classNames(
'MediaEditor__crop-preset MediaEditor__crop-preset--square',
{
'MediaEditor__crop-preset--selected':
cropPreset === CropPreset.Square,
}
)}
onClick={() => setCropPreset(CropPreset.Square)}
type="button"
>
{i18n('icu:MediaEditor__crop-preset--square')}
</button>
<button
className={classNames(
'MediaEditor__crop-preset MediaEditor__crop-preset--vertical',
{
'MediaEditor__crop-preset--selected':
cropPreset === CropPreset.Vertical,
}
)}
onClick={() => setCropPreset(CropPreset.Vertical)}
type="button"
>
{i18n('icu:MediaEditor__crop-preset--9-16')}
</button>
</div>
<div className="MediaEditor__tools-row-2">
<Button
disabled={!canReset}
onClick={async () => {
if (!fabricCanvas) {
return;
}
const newImageState = {
...INITIAL_IMAGE_STATE,
height: image.height,
width: image.width,
};
setImageState(newImageState);
setCropPreset(CropPreset.Freeform);
moveFabricObjectsForReset(fabricCanvas, imageState);
takeSnapshot('reset', newImageState);
}}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('icu:MediaEditor__crop--reset')}
</Button>
<div className="MediaEditor__toolbar__crop">
<button
aria-label={i18n('icu:MediaEditor__crop--rotate')}
className="MediaEditor__toolbar__crop__button MediaEditor__toolbar__button--rotate"
onClick={() => {
if (!fabricCanvas) {
return;
}
fabricCanvas.getObjects().forEach(obj => {
if (obj instanceof MediaEditorFabricCropRect) {
return;
}
const center = obj.getCenterPoint();
obj.set('angle', ((obj.angle || 0) + 270) % 360);
obj.setPositionByOrigin(
new fabric.Point(center.y, imageState.width - center.x),
'center',
'center'
);
obj.setCoords();
});
const newImageState = {
...imageState,
angle: (imageState.angle + 270) % 360,
height: imageState.width,
width: imageState.height,
};
setImageState(newImageState);
takeSnapshot('rotate', newImageState);
}}
type="button"
/>
<button
aria-label={i18n('icu:MediaEditor__crop--flip')}
className="MediaEditor__toolbar__crop__button MediaEditor__toolbar__button--flip"
onClick={() => {
if (!fabricCanvas) {
return;
}
const newImageState = {
...imageState,
...(imageState.angle % 180
? { flipY: !imageState.flipY }
: { flipX: !imageState.flipX }),
};
setImageState(newImageState);
takeSnapshot('flip', newImageState);
}}
type="button"
/>
<button
aria-label={i18n('icu:MediaEditor__crop--lock')}
className={classNames(
'MediaEditor__toolbar__crop__button',
`MediaEditor__toolbar__button--crop-${
cropAspectRatioLock ? '' : 'un'
}locked`
)}
onClick={() => {
if (fabricCanvas) {
fabricCanvas.uniformScaling = !cropAspectRatioLock;
}
setCropAspectRatioLock(!cropAspectRatioLock);
}}
type="button"
/>
</div>
<Button
onClick={() => {
if (!canCrop) {
setEditMode(undefined);
return;
}
if (!fabricCanvas) {
return;
}
const pendingCrop = getPendingCrop(fabricCanvas);
if (!pendingCrop) {
return;
}
const newImageState = getNewImageStateFromCrop(
imageState,
pendingCrop
);
setImageState(newImageState);
moveFabricObjectsForCrop(fabricCanvas, pendingCrop);
takeSnapshot('crop', newImageState);
setEditMode(undefined);
setCropPreset(CropPreset.Freeform);
}}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('icu:done')}
</Button>
</div>
</>
);
}
return createPortal(
<div className="MediaEditor">
<div className="MediaEditor__history-buttons">
<button
aria-label={i18n('icu:MediaEditor__control--undo')}
className="MediaEditor__control MediaEditor__control--undo"
disabled={!canUndo}
onClick={() => {
if (editMode === EditMode.Crop) {
setEditMode(undefined);
}
undoIfPossible();
}}
type="button"
/>
<button
aria-label={i18n('icu:MediaEditor__control--redo')}
className="MediaEditor__control MediaEditor__control--redo"
disabled={!canRedo}
onClick={() => {
if (editMode === EditMode.Crop) {
setEditMode(undefined);
}
redoIfPossible();
}}
type="button"
/>
</div>
<button
aria-label={i18n('icu:close')}
className="MediaEditor__close"
onClick={onTryClose}
type="button"
/>
<div className="MediaEditor__container">
<SizeObserver
onSizeChange={size => {
setContainerWidth(size.width);
setContainerHeight(size.height);
}}
>
{ref => (
<div className="MediaEditor__media" ref={ref}>
{image && (
<div>
<canvas
className={classNames('MediaEditor__media--canvas', {
'MediaEditor__media--canvas--cropping':
editMode === EditMode.Crop,
})}
id={canvasId}
/>
</div>
)}
</div>
)}
</SizeObserver>
</div>
<div className="MediaEditor__tools">
{toolElement !== undefined ? (
toolElement
) : (
<>
<div className="MediaEditor__tools-row-1">
<button
aria-label={i18n('icu: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('icu:MediaEditor__control--text')}
className={classNames({
MediaEditor__control: true,
'MediaEditor__control--text': true,
'MediaEditor__control--selected': editMode === EditMode.Text,
})}
onClick={() => {
if (editMode === EditMode.Text) {
setEditMode(undefined);
const obj = fabricCanvas?.getActiveObject();
if (obj instanceof MediaEditorFabricIText) {
obj.exitEditing();
}
} else {
setEditMode(EditMode.Text);
}
}}
type="button"
/>
<button
aria-label={i18n('icu: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"
/>
<StickerButton
blessedPacks={[]}
className={classNames({
MediaEditor__control: true,
'MediaEditor__control--sticker': true,
})}
onOpenStateChanged={value => {
setIsStickerPopperOpen(value);
}}
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);
}}
onPickTimeSticker={(style: 'analog' | 'digital') => {
if (!fabricCanvas) {
return;
}
if (style === 'digital') {
const sticker = new MediaEditorFabricDigitalTimeSticker(
Date.now()
);
sticker.setPositionByOrigin(
new fabric.Point(
imageState.width / 2,
imageState.height / 2
),
'center',
'center'
);
sticker.setCoords();
fabricCanvas.add(sticker);
fabricCanvas.setActiveObject(sticker);
}
if (style === 'analog') {
const sticker = new MediaEditorFabricAnalogTimeSticker();
const STICKER_SIZE_RELATIVE_TO_CANVAS = 4;
const size =
Math.min(imageState.width, imageState.height) /
STICKER_SIZE_RELATIVE_TO_CANVAS;
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}
/>
</div>
<div className="MediaEditor__tools-row-2">
<div className="MediaEditor__tools--input dark-theme">
<CompositionInput
draftText={caption}
draftBodyRanges={hydratedBodyRanges ?? null}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
isActive
isFormattingEnabled={isFormattingEnabled}
moduleClassName="StoryViewsNRepliesModal__input"
onCloseLinkPreview={noop}
onEditorStateChange={({ bodyRanges, messageText }) => {
setCaptionBodyRanges(bodyRanges);
setCaption(messageText);
}}
skinTone={skinTone ?? null}
onPickEmoji={onPickEmoji}
onSubmit={noop}
onTextTooLong={onTextTooLong}
placeholder={i18n('icu:MediaEditor__input-placeholder')}
platform={platform}
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark}
// Only needed for state updates and we need to override those
conversationId={null}
// Cannot enter media editor while editing
draftEditMessage={null}
// We don't use the large editor mode
large={null}
// panels do not appear over the media editor
shouldHidePopovers={null}
// link previews not displayed with media
linkPreviewResult={null}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onOpen={() => setEmojiPopperOpen(true)}
onClose={closeEmojiPickerAndFocusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
</div>
<Button
disabled={!image || isSaving || isSending}
onClick={async () => {
if (!fabricCanvas) {
return;
}
setEditMode(undefined);
setIsSaving(true);
let data: Uint8Array;
let blurHash: string;
try {
const renderFabricCanvas =
await cloneFabricCanvas(fabricCanvas);
renderFabricCanvas.remove(
...renderFabricCanvas
.getObjects()
.filter(obj => obj.excludeFromExport)
);
let finalImageState: ImageStateType;
const pendingCrop = getPendingCrop(fabricCanvas);
if (pendingCrop) {
finalImageState = getNewImageStateFromCrop(
imageState,
pendingCrop
);
moveFabricObjectsForCrop(renderFabricCanvas, pendingCrop);
drawFabricBackgroundImage({
fabricCanvas: renderFabricCanvas,
image,
imageState: finalImageState,
});
} else {
finalImageState = imageState;
}
renderFabricCanvas.setDimensions({
width: finalImageState.width,
height: finalImageState.height,
});
renderFabricCanvas.setZoom(1);
const renderedCanvas = renderFabricCanvas.toCanvasElement();
data = await canvasToBytes(renderedCanvas);
const blob = new Blob([data], {
type: IMAGE_PNG,
});
blurHash = await props.imageToBlurHash(blob);
} catch (err) {
onTryClose();
throw err;
} finally {
setIsSaving(false);
}
onDone({
contentType: IMAGE_PNG,
data,
caption: caption !== '' ? caption : undefined,
captionBodyRanges: captionBodyRanges ?? undefined,
blurHash,
});
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{isSending ? (
<Spinner svgSize="small" />
) : (
doneButtonLabel || i18n('icu:save')
)}
</Button>
</div>
</>
)}
</div>
{confirmDiscardModal}
</div>,
portal
);
}
function getPendingCrop(
fabricCanvas: fabric.Canvas
): undefined | PendingCropType {
const activeObject = fabricCanvas.getActiveObject();
return activeObject instanceof MediaEditorFabricCropRect
? activeObject.getBoundingRect(true)
: undefined;
}
function getNewImageStateFromCrop(
state: Readonly<ImageStateType>,
{ left, height, top, width }: Readonly<PendingCropType>
): ImageStateType {
let cropX: number;
let cropY: number;
switch (state.angle) {
case 0:
cropX = state.cropX + left;
cropY = state.cropY + top;
break;
case 90:
cropX = state.cropX + top;
cropY = state.cropY + (state.width - (left + width));
break;
case 180:
cropX = state.cropX + (state.width - (left + width));
cropY = state.cropY + (state.height - (top + height));
break;
case 270:
cropX = state.cropX + (state.height - (top + height));
cropY = state.cropY + left;
break;
default:
throw new Error('Unexpected angle');
}
return {
...state,
cropX,
cropY,
height,
width,
};
}
function cloneFabricCanvas(original: fabric.Canvas): Promise<fabric.Canvas> {
return new Promise(resolve => {
original.clone(resolve, ['data']);
});
}
function moveFabricObjectsForCrop(
fabricCanvas: fabric.Canvas,
{ left, top }: Readonly<PendingCropType>
): void {
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();
});
}
function moveFabricObjectsForReset(
fabricCanvas: fabric.Canvas,
oldImageState: Readonly<ImageStateType>
): void {
fabricCanvas.getObjects().forEach(obj => {
if (obj.excludeFromExport) {
return;
}
let newCenterX: number;
let newCenterY: number;
// First, reset position changes caused by image rotation:
const oldCenter = obj.getCenterPoint();
const distanceFromRightEdge = oldImageState.width - oldCenter.x;
const distanceFromBottomEdge = oldImageState.height - oldCenter.y;
switch (oldImageState.angle % 360) {
case 0:
newCenterX = oldCenter.x;
newCenterY = oldCenter.y;
break;
case 90:
newCenterX = oldCenter.y;
newCenterY = distanceFromRightEdge;
break;
case 180:
newCenterX = distanceFromRightEdge;
newCenterY = distanceFromBottomEdge;
break;
case 270:
newCenterX = distanceFromBottomEdge;
newCenterY = oldCenter.x;
break;
default:
throw new Error('Unexpected angle');
}
// Next, reset position changes caused by crop:
newCenterX += oldImageState.cropX;
newCenterY += oldImageState.cropY;
// It's important to set the angle *before* setting the position, because
// Fabric's positioning is affected by object angle.
obj.set('angle', (obj.angle || 0) - oldImageState.angle);
obj.setPositionByOrigin(
new fabric.Point(newCenterX, newCenterY),
'center',
'center'
);
obj.setCoords();
});
}
function drawFabricBackgroundImage({
fabricCanvas,
image,
imageState,
}: Readonly<{
fabricCanvas: fabric.Canvas;
image: HTMLImageElement;
imageState: Readonly<ImageStateType>;
}>): void {
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,
}
);
}