// 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; 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 const MediaEditor = ({ i18n, imageSrc, onClose, onDone, // StickerButtonProps installedPacks, recentStickers, }: PropsType): JSX.Element | null => { const [fabricCanvas, setFabricCanvas] = useState(); const [image, setImage] = useState(new Image()); const isRestoringImageState = useRef(false); const canvasId = useUniqueId(); const [imageState, setImageState] = useState({ angle: 0, cropX: 0, cropY: 0, flipX: false, flipY: false, height: image.height, width: image.width, }); // 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); setImageState(curr => ({ ...curr, height: img.height, width: img.width, })); }; 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]); const history = useFabricHistory(fabricCanvas); // 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', () => { if (history?.canUndo()) { history?.undo(); } }, ], [ ev => isCmdOrCtrl(ev) && ev.shiftKey && ev.key === 'z', () => { if (history?.canRedo()) { history?.redo(); } }, ], [ ev => ev.key === 'Escape', () => { 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, history]); // 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(() => { if (!fabricCanvas) { return; } drawFabricBackgroundImage({ fabricCanvas, image, imageState }); }, [fabricCanvas, image, imageState]); const [canRedo, setCanRedo] = useState(false); const [canUndo, setCanUndo] = useState(false); const [canCrop, setCanCrop] = useState(false); const [cropAspectRatioLock, setcropAspectRatioLock] = useState(false); const [drawTool, setDrawTool] = useState(DrawTool.Pen); const [drawWidth, setDrawWidth] = useState(DrawWidth.Regular); const [editMode, setEditMode] = useState(); const [sliderValue, setSliderValue] = useState(0); const [textStyle, setTextStyle] = useState(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) { isRestoringImageState.current = true; setImageState(curr => ({ ...curr, ...prevImageState })); } function takeSnapshot() { history?.takeSnapshot({ ...imageState }); } history.on('appliedState', restoreImageState); history.on('historyChanged', refreshUndoState); history.on('pleaseTakeSnapshot', takeSnapshot); return () => { history.off('appliedState', restoreImageState); history.off('historyChanged', refreshUndoState); history.off('pleaseTakeSnapshot', takeSnapshot); }; }, [history, imageState]); // If you select a text path auto enter edit mode useEffect(() => { if (!fabricCanvas) { return; } function updateEditMode() { if (fabricCanvas?.getActiveObject() instanceof 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; } const obj = fabricCanvas.getActiveObject(); if (!obj || !(obj instanceof MediaEditorFabricIText)) { return; } obj.exitEditing(); obj.set(getTextStyleAttributes(textStyle, sliderValue)); fabricCanvas.requestRenderAll(); }, [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(value)} theme={Theme.Dark} value={textStyle} /> ); } else if (editMode === EditMode.Draw) { tooling = ( <> setDrawTool(value)} theme={Theme.Dark} value={drawTool} /> 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 = ( <> ); } return createPortal(
{ if (!bounds) { log.error('We should be measuring the bounds'); return; } setContainerWidth(bounds.width); setContainerHeight(bounds.height); }} > {({ measureRef }) => (
{image && (
)}
)}
{tooling ? (
{tooling}
) : (
)}
, 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 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, } ); }