// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import Measure from 'react-measure'; import React, { useCallback, useEffect, 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 { ThemeType } from '../types/Util'; import type { MIMEType } from '../types/MIME'; import { IMAGE_PNG } from '../types/MIME'; 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 type { imageToBlurHash } from '../util/imageToBlurHash'; 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 { fabricEffectListener } from '../mediaEditor/fabricEffectListener'; import { getRGBA, getHSL } from '../mediaEditor/util/color'; import { TextStyle, getTextStyleAttributes, } from '../mediaEditor/util/getTextStyleAttributes'; import { AddCaptionModal } from './AddCaptionModal'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; import { Emojify } from './conversation/Emojify'; import { AddNewLines } from './conversation/AddNewLines'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Spinner } from './Spinner'; export type MediaEditorResultType = Readonly<{ data: Uint8Array; contentType: MIMEType; blurHash: string; caption?: string; }>; export type PropsType = { doneButtonLabel?: string; i18n: LocalizerType; imageSrc: string; isSending: boolean; imageToBlurHash: typeof imageToBlurHash; onClose: () => unknown; onDone: (result: MediaEditorResultType) => unknown; } & Pick & ( | { supportsCaption: true; renderCompositionTextArea: ( props: SmartCompositionTextAreaProps ) => JSX.Element; } | { supportsCaption?: false; renderCompositionTextArea?: undefined; } ); 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', } 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, // StickerButtonProps installedPacks, recentStickers, ...props }: PropsType): JSX.Element | null { const [fabricCanvas, setFabricCanvas] = useState(); const [image, setImage] = useState(new Image()); const [isStickerPopperOpen, setIsStickerPopperOpen] = useState(false); const [caption, setCaption] = useState(''); const [showAddCaptionModal, setShowAddCaptionModal] = useState(false); const canvasId = useUniqueId(); const [imageState, setImageState] = useState(INITIAL_IMAGE_STATE); // 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(': 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(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n); const onTryClose = useCallback(() => { confirmDiscardIf(caption !== '' || Boolean(image), onClose); }, [confirmDiscardIf, caption, image, 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', () => { // 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 === 'ArrowLeft', (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 === 'ArrowRight', (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, 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.Pen); const [drawWidth, setDrawWidth] = useState(DrawWidth.Regular); const [sliderValue, setSliderValue] = useState(0); const [textStyle, setTextStyle] = useState(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]); // 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 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 = ( <> setTextStyle(TextStyle.Regular), value: TextStyle.Regular, }, { icon: 'MediaEditor__icon--text-highlight', label: i18n('MediaEditor__text--highlight'), onClick: () => setTextStyle(TextStyle.Highlight), value: TextStyle.Highlight, }, { icon: 'MediaEditor__icon--text-outline', label: i18n('MediaEditor__text--outline'), onClick: () => setTextStyle(TextStyle.Outline), value: TextStyle.Outline, }, ]} moduleClassName={classNames('MediaEditor__tools__tool', { 'MediaEditor__tools__button--text-regular': textStyle === TextStyle.Regular, 'MediaEditor__tools__button--text-highlight': textStyle === TextStyle.Highlight, 'MediaEditor__tools__button--text-outline': textStyle === TextStyle.Outline, })} theme={Theme.Dark} value={textStyle} /> ); } else if (editMode === EditMode.Draw) { tooling = ( <> setDrawTool(DrawTool.Pen), value: DrawTool.Pen, }, { icon: 'MediaEditor__icon--draw-highlighter', label: i18n('MediaEditor__draw--highlighter'), onClick: () => setDrawTool(DrawTool.Highlighter), value: DrawTool.Highlighter, }, ]} moduleClassName={classNames('MediaEditor__tools__tool', { 'MediaEditor__tools__button--draw-pen': drawTool === DrawTool.Pen, 'MediaEditor__tools__button--draw-highlighter': drawTool === DrawTool.Highlighter, })} theme={Theme.Dark} value={drawTool} /> setDrawWidth(DrawWidth.Thin), value: DrawWidth.Thin, }, { icon: 'MediaEditor__icon--width-regular', label: i18n('MediaEditor__draw--regular'), onClick: () => setDrawWidth(DrawWidth.Regular), value: DrawWidth.Regular, }, { icon: 'MediaEditor__icon--width-medium', label: i18n('MediaEditor__draw--medium'), onClick: () => setDrawWidth(DrawWidth.Medium), value: DrawWidth.Medium, }, { icon: 'MediaEditor__icon--width-heavy', label: i18n('MediaEditor__draw--heavy'), onClick: () => setDrawWidth(DrawWidth.Heavy), value: DrawWidth.Heavy, }, ]} moduleClassName={classNames('MediaEditor__tools__tool', { 'MediaEditor__tools__button--width-thin': drawWidth === DrawWidth.Thin, 'MediaEditor__tools__button--width-regular': drawWidth === DrawWidth.Regular, 'MediaEditor__tools__button--width-medium': drawWidth === DrawWidth.Medium, 'MediaEditor__tools__button--width-heavy': drawWidth === DrawWidth.Heavy, })} 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 = ( <> ); } return createPortal(
{ if (!bounds) { log.error('We should be measuring the bounds'); return; } setContainerWidth(bounds.width); setContainerHeight(bounds.height); }} > {({ measureRef }) => (
{image && (
)}
)}
{tooling ? (
{tooling}
) : ( <> {props.supportsCaption ? (
{showAddCaptionModal && ( { setCaption(messageText.trim()); setShowAddCaptionModal(false); }} onClose={() => setShowAddCaptionModal(false)} RenderCompositionTextArea={props.renderCompositionTextArea} theme={ThemeType.dark} /> )}
) : (
)} )}
{confirmDiscardModal}
, portal ); } function getPendingCrop( fabricCanvas: fabric.Canvas ): undefined | PendingCropType { const activeObject = fabricCanvas.getActiveObject(); return activeObject instanceof MediaEditorFabricCropRect ? activeObject.getBoundingRect(true) : undefined; } function getNewImageStateFromCrop( state: Readonly, { left, height, top, width }: Readonly ): 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 { return new Promise(resolve => { original.clone(resolve); }); } function moveFabricObjectsForCrop( fabricCanvas: fabric.Canvas, { left, top }: Readonly ): 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 ): 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; }>): 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, } ); }