// 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', } 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(() => { 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, 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.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) { 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 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 ); };