UNPKG

@atlaskit/editor-plugin-media-insert

Version:

Media Insert plugin for @atlaskit/editor-core

366 lines 13.6 kB
import _extends from "@babel/runtime/helpers/extends"; import React, { Fragment } from 'react'; import { useIntl } from 'react-intl'; import { isSafeUrl } from '@atlaskit/adf-schema'; import ButtonGroup from '@atlaskit/button/button-group'; import Button from '@atlaskit/button/new'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { mediaInsertMessages } from '@atlaskit/editor-common/messages'; import Form, { ErrorMessage, Field, FormFooter, HelperMessage, MessageWrapper } from '@atlaskit/form'; import ExpandIcon from '@atlaskit/icon/core/grow-diagonal'; import { getMediaClient } from '@atlaskit/media-client-react'; import { fg } from '@atlaskit/platform-feature-flags'; // eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss import { Box, Flex, Inline, Stack, xcss } from '@atlaskit/primitives'; import SectionMessage from '@atlaskit/section-message'; import TextField from '@atlaskit/textfield'; import { MediaCard } from './MediaCard'; import { useAnalyticsEvents } from './useAnalyticsEvents'; const PreviewBoxStyles = xcss({ borderWidth: 'border.width', borderStyle: 'dashed', borderColor: 'color.border', borderRadius: 'radius.small', height: '200px' }); const PreviewImageStyles = xcss({ height: '200px' }); const FormStyles = xcss({ flexGrow: 1 }); const INITIAL_PREVIEW_STATE = Object.freeze({ isLoading: false, error: null, warning: null, previewInfo: null }); const isValidInput = (value, customizedUrlValidation) => { if (customizedUrlValidation) { return customizedUrlValidation(value); } return isValidUrl(value); }; const MAX_URL_LENGTH = 2048; export const isValidUrl = value => { try { // Check for spaces and length first to avoid the expensive URL parsing // Ignored via go/ees005 // eslint-disable-next-line require-unicode-regexp if (/\s/.test(value) || value.length > MAX_URL_LENGTH) { return false; } new URL(value); } catch { return false; } return isSafeUrl(value); }; const previewStateReducer = (state, action) => { switch (action.type) { case 'loading': return { ...INITIAL_PREVIEW_STATE, isLoading: true }; case 'error': return { ...INITIAL_PREVIEW_STATE, error: action.error }; case 'warning': return { ...INITIAL_PREVIEW_STATE, warning: action.warning }; case 'success': return { ...INITIAL_PREVIEW_STATE, previewInfo: action.payload }; case 'reset': return INITIAL_PREVIEW_STATE; default: return state; } }; export function MediaFromURL({ mediaProvider, dispatchAnalyticsEvent, closeMediaInsertPicker, insertMediaSingle, insertExternalMediaSingle, isOnlyExternalLinks, customizedUrlValidation, customizedHelperMessage }) { const intl = useIntl(); const strings = { loadPreview: intl.formatMessage(mediaInsertMessages.loadPreview), insert: intl.formatMessage(mediaInsertMessages.insert), pasteLinkToUpload: intl.formatMessage(mediaInsertMessages.pasteLinkToUpload), cancel: intl.formatMessage(mediaInsertMessages.cancel), errorMessage: intl.formatMessage(mediaInsertMessages.fromUrlErrorMessage), warning: intl.formatMessage(mediaInsertMessages.fromUrlWarning), invalidUrl: intl.formatMessage(mediaInsertMessages.invalidUrlErrorMessage) }; const [previewState, dispatch] = React.useReducer(previewStateReducer, INITIAL_PREVIEW_STATE); const [input, setInput] = React.useState(''); const pasteFlag = React.useRef(false); const { onUploadButtonClickedAnalytics, onUploadCommencedAnalytics, onUploadSuccessAnalytics, onUploadFailureAnalytics } = useAnalyticsEvents(dispatchAnalyticsEvent); const uploadExternalMedia = React.useCallback(async url => { onUploadButtonClickedAnalytics(); dispatch({ type: 'loading' }); const { uploadMediaClientConfig, uploadParams } = mediaProvider; if (!uploadMediaClientConfig) { return; } const mediaClient = getMediaClient(uploadMediaClientConfig); const collection = uploadParams === null || uploadParams === void 0 ? void 0 : uploadParams.collection; onUploadCommencedAnalytics('url'); try { const { uploadableFileUpfrontIds, dimensions, mimeType } = await mediaClient.file.uploadExternal(url, collection); onUploadSuccessAnalytics('url'); dispatch({ type: 'success', payload: { id: uploadableFileUpfrontIds.id, collection, dimensions, occurrenceKey: uploadableFileUpfrontIds.occurrenceKey, fileMimeType: mimeType } // eslint-disable-next-line no-unused-vars }); } catch (e) { // eslint-disable-line no-unused-vars if (typeof e === 'string' && e === 'Could not download remote file') { // TODO: ED-26962 - Make sure this gets good unit test coverage with the actual media plugin. // This hard coded error message could be changed at any // point and we need a unit test to break to stop people changing it. onUploadFailureAnalytics(e, 'url'); dispatch({ type: 'warning', warning: e, url }); } else if (e instanceof Error) { const message = 'Image preview fetch failed'; onUploadFailureAnalytics(message, 'url'); dispatch({ type: 'error', error: message }); } else { onUploadFailureAnalytics('Unknown error', 'url'); dispatch({ type: 'error', error: 'Unknown error' }); } } }, [onUploadButtonClickedAnalytics, mediaProvider, onUploadCommencedAnalytics, onUploadSuccessAnalytics, onUploadFailureAnalytics]); const onURLChange = React.useCallback(e => { const url = e.currentTarget.value; dispatch({ type: 'reset' }); setInput(url); if (!isValidInput(url, customizedUrlValidation)) { return; } if (pasteFlag.current) { pasteFlag.current = false; if (!isOnlyExternalLinks) { uploadExternalMedia(url); } } }, [uploadExternalMedia, isOnlyExternalLinks, customizedUrlValidation]); const onPaste = React.useCallback((e, inputUrl) => { // Note: this is a little weird, but the paste event will always be // fired before the change event when pasting. We don't really want to // duplicate logic by handling pastes separately to changes, so we're // just noting paste occurred to then be handled in the onURLChange fn // above. The one exception to this is where paste inputs exactly what was // already in the input, in which case we want to ignore it. if (e.clipboardData.getData('text') !== inputUrl) { pasteFlag.current = true; } }, []); const onInsert = React.useCallback(() => { if (previewState.previewInfo) { insertMediaSingle({ mediaState: previewState.previewInfo, inputMethod: INPUT_METHOD.MEDIA_PICKER }); } closeMediaInsertPicker(); }, [closeMediaInsertPicker, insertMediaSingle, previewState.previewInfo]); const onExternalInsert = React.useCallback(url => { if (previewState.warning || isOnlyExternalLinks) { insertExternalMediaSingle({ url, alt: '', inputMethod: INPUT_METHOD.MEDIA_PICKER }); } closeMediaInsertPicker(); }, [closeMediaInsertPicker, insertExternalMediaSingle, previewState.warning, isOnlyExternalLinks]); const onInputKeyPress = React.useCallback(event => { if (event && event.key === 'Esc') { if (dispatchAnalyticsEvent) { const payload = { action: ACTION.CLOSED, actionSubject: ACTION_SUBJECT.PICKER, actionSubjectId: ACTION_SUBJECT_ID.PICKER_MEDIA, eventType: EVENT_TYPE.UI, attributes: { exitMethod: INPUT_METHOD.KEYBOARD } }; dispatchAnalyticsEvent(payload); } closeMediaInsertPicker(); } }, [dispatchAnalyticsEvent, closeMediaInsertPicker]); const onCancel = React.useCallback(() => { if (dispatchAnalyticsEvent) { const payload = { action: ACTION.CANCELLED, actionSubject: ACTION_SUBJECT.PICKER, actionSubjectId: ACTION_SUBJECT_ID.PICKER_MEDIA, eventType: EVENT_TYPE.UI }; dispatchAnalyticsEvent(payload); } closeMediaInsertPicker(); }, [closeMediaInsertPicker, dispatchAnalyticsEvent]); return /*#__PURE__*/React.createElement(Form, { // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) onSubmit: ({ inputUrl }, form) => { // This can be triggered from an enter key event on the input even when // the button is disabled, so we explicitly do nothing when in loading // state. if (previewState.isLoading || form.getState().invalid) { return; } if (previewState.previewInfo) { return onInsert(); } if (previewState.warning || isOnlyExternalLinks) { return onExternalInsert(inputUrl); } return uploadExternalMedia(inputUrl); } }, ({ formProps }) => /*#__PURE__*/ // Ignored via go/ees005 // eslint-disable-next-line react/jsx-props-no-spreading React.createElement(Box, { xcss: FormStyles }, /*#__PURE__*/React.createElement(Stack, { space: "space.150", grow: "fill" }, /*#__PURE__*/React.createElement(Field, { isRequired: true, name: "inputUrl" // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , validate: value => value && isValidInput(value, customizedUrlValidation) ? undefined : strings.invalidUrl }, ({ fieldProps: { value, onChange, ...rest }, error, meta }) => /*#__PURE__*/React.createElement(Stack, { space: "space.150", grow: "fill" }, /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(TextField // Ignored via go/ees005 // eslint-disable-next-line react/jsx-props-no-spreading , _extends({}, rest, { value: value, "aria-label": fg('platform_editor_nov_a11y_fixes') ? strings.pasteLinkToUpload : undefined, placeholder: strings.pasteLinkToUpload, maxLength: MAX_URL_LENGTH, onKeyPress: onInputKeyPress // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onPaste: event => onPaste(event, value) // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onChange: value => { onURLChange(value); onChange(value); } // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onKeyDown: e => { if (e.key === 'Enter') { e.preventDefault(); formProps.onSubmit(); } } })), customizedHelperMessage && /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(HelperMessage, null, customizedHelperMessage)), /*#__PURE__*/React.createElement(MessageWrapper, null, error && /*#__PURE__*/React.createElement(ErrorMessage, null, /*#__PURE__*/React.createElement(Box, { as: "span" }, error)))), !previewState.previewInfo && !previewState.error && !previewState.warning && !isOnlyExternalLinks && /*#__PURE__*/React.createElement(Flex, { xcss: PreviewBoxStyles, alignItems: "center", justifyContent: "center" }, /*#__PURE__*/React.createElement(Button, { type: "button" // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onClick: () => formProps.onSubmit(), isLoading: previewState.isLoading, isDisabled: !!error || !meta.dirty // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , iconBefore: () => /*#__PURE__*/React.createElement(ExpandIcon, { label: "" }) }, strings.loadPreview)))), previewState.previewInfo && /*#__PURE__*/React.createElement(Inline, { alignInline: "center", alignBlock: "center", xcss: PreviewImageStyles, space: "space.200" }, /*#__PURE__*/React.createElement(MediaCard, { attrs: previewState.previewInfo, mediaProvider: mediaProvider })), /*#__PURE__*/React.createElement(MessageWrapper, null, previewState.error && /*#__PURE__*/React.createElement(SectionMessage, { appearance: "error" }, strings.errorMessage), previewState.warning && /*#__PURE__*/React.createElement(SectionMessage, { appearance: "warning" }, strings.warning)), /*#__PURE__*/React.createElement(FormFooter, null, /*#__PURE__*/React.createElement(ButtonGroup, null, /*#__PURE__*/React.createElement(Button, { appearance: "subtle", onClick: onCancel }, strings.cancel), /*#__PURE__*/React.createElement(Button, { type: "button", appearance: "primary", isDisabled: isOnlyExternalLinks ? !input || !isValidInput(input, customizedUrlValidation) : !previewState.previewInfo && !previewState.warning // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onClick: () => formProps.onSubmit() }, strings.insert)))))); }