diff --git a/app/javascript/article-form/components/EditorBody.jsx b/app/javascript/article-form/components/EditorBody.jsx index 9b3288f3d..cefba279b 100644 --- a/app/javascript/article-form/components/EditorBody.jsx +++ b/app/javascript/article-form/components/EditorBody.jsx @@ -3,12 +3,14 @@ import PropTypes from 'prop-types'; import Textarea from 'preact-textarea-autosize'; import { useEffect, useRef } from 'preact/hooks'; import { Toolbar } from './Toolbar'; +import { handleImagePasted } from './pasteImageHelpers'; import { handleImageDrop, handleImageFailure, onDragOver, onDragExit, } from './dragAndDropHelpers'; +import { usePasteImage } from '@utilities/pasteImage'; import { useDragAndDrop } from '@utilities/dragAndDrop'; function handleImageSuccess(textAreaRef) { @@ -17,7 +19,9 @@ function handleImageSuccess(textAreaRef) { // textarea ref. const editableBodyElement = textAreaRef.current.base; const { links, image } = response; - const altText = image[0].name.replace(/\.[^.]+$/, ''); + const altText = image[0] + ? image[0].name.replace(/\.[^.]+$/, '') + : 'alt text'; const markdownImageLink = `![${altText}](${links[0]})\n`; const { selectionStart, selectionEnd, value } = editableBodyElement; const before = value.substring(0, selectionStart); @@ -50,9 +54,17 @@ export const EditorBody = ({ onDragExit, }); + const setPasteElement = usePasteImage({ + onPaste: handleImagePasted( + handleImageSuccess(textAreaRef), + handleImageFailure, + ), + }); + useEffect(() => { if (textAreaRef.current) { setElement(textAreaRef.current.base); + setPasteElement(textAreaRef.current.base); } }); diff --git a/app/javascript/article-form/components/__tests__/pasteImageHelpers.test.jsx b/app/javascript/article-form/components/__tests__/pasteImageHelpers.test.jsx new file mode 100644 index 000000000..5ea5ff4c2 --- /dev/null +++ b/app/javascript/article-form/components/__tests__/pasteImageHelpers.test.jsx @@ -0,0 +1,29 @@ +import { matchesDataTransferType } from '../pasteImageHelpers'; + +describe('pasteImageHelpers', () => { + describe('matchesDataTransferType', () => { + it('returns false if no types are provided', () => { + expect(matchesDataTransferType([])).toBe(false); + }); + + it('returns true if at least one type matches Files', () => { + const result = matchesDataTransferType(['example', 'Files', 'other']); + expect(result).toBe(true); + }); + + it('returns false if no types match type Files', () => { + const result = matchesDataTransferType(['example', 'other']); + expect(result).toBe(false); + }); + + it('returns true if at least one type matches a custom type provided', () => { + const result = matchesDataTransferType(['example', 'other'], 'other'); + expect(result).toBe(true); + }); + + it('returns false if no types match a custom type provided', () => { + const result = matchesDataTransferType(['example', 'Files'], 'other'); + expect(result).toBe(false); + }); + }); +}); diff --git a/app/javascript/article-form/components/pasteImageHelpers.js b/app/javascript/article-form/components/pasteImageHelpers.js new file mode 100644 index 000000000..9016bfe68 --- /dev/null +++ b/app/javascript/article-form/components/pasteImageHelpers.js @@ -0,0 +1,55 @@ +import { addSnackbarItem } from '../../Snackbar'; +import { processImageUpload } from '../actions'; + +/** + * Determines if at least one type of drag and drop datum type matches the data transfer type to match. + * + * @param {string[]} types An array of data transfer types. + * @param {string} dataTransferType The data transfer type to match. + */ +export function matchesDataTransferType( + types = [], + dataTransferType = 'Files', +) { + return types.some((type) => type === dataTransferType); +} + +/** + * Handler for when image is pasted. + * + * @param {function} handleImageSuccess Callback for when image upload succeeds + * @param {function} handleImageFailure Callback for when image upload fails + */ +export function handleImagePasted(handleImageSuccess, handleImageFailure) { + return function (event) { + if (!event.clipboardData || !event.clipboardData.items) return; + if (!matchesDataTransferType(event.clipboardData.types)) return; + + event.preventDefault(); + + const { files } = event.clipboardData; + + if (files.length > 1) { + addSnackbarItem({ + message: 'Only one image can be pasted at a time.', + addCloseButton: true, + }); + return; + } + + processImageUpload(files, handleImageSuccess, handleImageFailure); + }; +} + +/** + * Handler for when image upload fails. + * + * @param {Error} error an error + * @param {string} error.message an error message + */ +export function handleImageFailure({ message }) { + addSnackbarItem({ + message, + addCloseButton: true, + }); +} diff --git a/app/javascript/utilities/__tests__/pasteImage.test.js b/app/javascript/utilities/__tests__/pasteImage.test.js new file mode 100644 index 000000000..2dd4d8b2e --- /dev/null +++ b/app/javascript/utilities/__tests__/pasteImage.test.js @@ -0,0 +1,25 @@ +import { renderHook, act } from '@testing-library/preact-hooks'; +import { fireEvent } from '@testing-library/preact'; +import { usePasteImage } from '../pasteImage'; + +describe('usePasteImage', () => { + it('listens for paste events on the set element', () => { + const handlePasteImage = jest.fn(); + + document.body.innerHTML = ` +
+ `; + const element = document.getElementById('paste-area'); + + const { + result: { current: setElement }, + } = renderHook(() => usePasteImage({ onPaste: handlePasteImage })); + + act(() => { + setElement(element); + }); + + fireEvent.paste(element, { clipboardData: {} }); + expect(handlePasteImage).toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/utilities/pasteImage.js b/app/javascript/utilities/pasteImage.js new file mode 100644 index 000000000..9b943b7f8 --- /dev/null +++ b/app/javascript/utilities/pasteImage.js @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'preact/hooks'; + +/** + * A custom Preact hook used to attach a catch all for pasting images to a DOM element. + * @example + * function SomeComponent(props) { + * const { setElement } = usePasteImage({ + * onPaste: somePasteHandler + * }); + * + * const someDomRef = useRef(null); + * + * useEffect(() => { + * if (someDomRef.current) { + * setElement(someDomRef.current); + * } + * }); + * + * return ; + * }; + * + * @param {object} props + * @param {Function} props.onPaste The handler that runs when the paste event is fired. + */ +export function usePasteImage({ onPaste }) { + const [element, setElement] = useState(null); + + useEffect(() => { + if (!element) return; + + element.addEventListener('paste', onPaste); + + return () => { + element.removeEventListener('paste', onPaste); + }; + }, [element, onPaste]); + + return setElement; +}