@atlaskit/editor-plugin-media-insert
Version:
Media Insert plugin for @atlaskit/editor-core
366 lines • 13.6 kB
JavaScript
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))))));
}