diff --git a/packages/netlify-cms-core/src/actions/mediaLibrary.ts b/packages/netlify-cms-core/src/actions/mediaLibrary.ts index 144068a2..266a324c 100644 --- a/packages/netlify-cms-core/src/actions/mediaLibrary.ts +++ b/packages/netlify-cms-core/src/actions/mediaLibrary.ts @@ -413,6 +413,7 @@ function mediaLibraryOpened(payload: { forImage?: boolean; privateUpload?: boolean; value?: string; + replaceIndex?: number; allowMultiple?: boolean; config?: Map; field?: EntryField; diff --git a/packages/netlify-cms-core/src/reducers/mediaLibrary.ts b/packages/netlify-cms-core/src/reducers/mediaLibrary.ts index 373f27a2..0a592dab 100644 --- a/packages/netlify-cms-core/src/reducers/mediaLibrary.ts +++ b/packages/netlify-cms-core/src/reducers/mediaLibrary.ts @@ -45,6 +45,8 @@ const defaultState: { files?: MediaFile[]; config: Map; field?: EntryField; + value?: string | string[]; + replaceIndex?: number; } = { isVisible: false, showMediaButton: true, @@ -62,7 +64,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { }); case MEDIA_LIBRARY_OPEN: { - const { controlID, forImage, privateUpload, config, field } = action.payload; + const { controlID, forImage, privateUpload, config, field, value, replaceIndex } = + action.payload; const libConfig = config || Map(); const privateUploadChanged = state.get('privateUpload') !== privateUpload; if (privateUploadChanged) { @@ -76,6 +79,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { controlMedia: Map(), displayURLs: Map(), field, + value, + replaceIndex, }); } return state.withMutations(map => { @@ -86,6 +91,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { map.set('privateUpload', privateUpload); map.set('config', libConfig); map.set('field', field); + map.set('value', value); + map.set('replaceIndex', replaceIndex); }); } @@ -95,8 +102,25 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { case MEDIA_INSERT: { const { mediaPath } = action.payload; const controlID = state.get('controlID'); + const value = state.get('value'); + + if (!Array.isArray(value)) { + return state.withMutations(map => { + map.setIn(['controlMedia', controlID], mediaPath); + }); + } + + const replaceIndex = state.get('replaceIndex'); + const mediaArray = Array.isArray(mediaPath) ? mediaPath : [mediaPath]; + const valueArray = value as string[]; + if (typeof replaceIndex == 'number') { + valueArray[replaceIndex] = mediaArray[0]; + } else { + valueArray.push(...mediaArray); + } + return state.withMutations(map => { - map.setIn(['controlMedia', controlID], mediaPath); + map.setIn(['controlMedia', controlID], valueArray); }); } diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 555d4f4e..400eaf6b 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -169,19 +169,25 @@ const en = { }, image: { choose: 'Choose an image', + chooseMultiple: 'Choose images', chooseUrl: 'Insert from URL', replaceUrl: 'Replace with URL', promptUrl: 'Enter the URL of the image', chooseDifferent: 'Choose different image', + addMore: 'Add more images', remove: 'Remove image', + removeAll: 'Remove all images', }, file: { choose: 'Choose a file', chooseUrl: 'Insert from URL', + chooseMultiple: 'Choose files', replaceUrl: 'Replace with URL', promptUrl: 'Enter the URL of the file', chooseDifferent: 'Choose different file', + addMore: 'Add more files', remove: 'Remove file', + removeAll: 'Remove all files', }, unknownControl: { noControl: "No control for widget '%{widget}'.", diff --git a/packages/netlify-cms-widget-file/src/withFileControl.js b/packages/netlify-cms-widget-file/src/withFileControl.js index 941b9c59..00932830 100644 --- a/packages/netlify-cms-widget-file/src/withFileControl.js +++ b/packages/netlify-cms-widget-file/src/withFileControl.js @@ -7,7 +7,15 @@ import { Map, List } from 'immutable'; import { once } from 'lodash'; import uuid from 'uuid/v4'; import { oneLine } from 'common-tags'; -import { lengths, components, buttons, borders, effects, shadows } from 'netlify-cms-ui-default'; +import { + lengths, + components, + buttons, + borders, + effects, + shadows, + IconButton, +} from 'netlify-cms-ui-default'; import { basename } from 'netlify-cms-lib-util'; import { SortableContainer, SortableElement } from 'react-sortable-hoc'; import { arrayMoveImmutable as arrayMove } from 'array-move'; @@ -28,6 +36,15 @@ const ImageWrapper = styled.div` cursor: ${props => (props.sortable ? 'pointer' : 'auto')}; `; +const SortableImageButtonsWrapper = styled.div` + display: flex; + justify-content: center; + column-gap: 10px; + margin-right: 20px; + margin-top: -10px; + margin-bottom: 10px; +`; + const StyledImage = styled.img` width: 100%; height: 100%; @@ -38,35 +55,55 @@ function Image(props) { return ; } -const SortableImage = SortableElement(({ itemValue, getAsset, field }) => { +function SortableImageButtons({ onRemove, onReplace }) { return ( - - - + + + + ); -}); +} -const SortableMultiImageWrapper = SortableContainer(({ items, getAsset, field }) => { +const SortableImage = SortableElement(({ itemValue, getAsset, field, onRemove, onReplace }) => { return ( -
- {items.map((itemValue, index) => ( - - ))} +
+ + + +
); }); +const SortableMultiImageWrapper = SortableContainer( + ({ items, getAsset, field, onRemoveOne, onReplaceOne }) => { + return ( +
+ {items.map((itemValue, index) => ( + + ))} +
+ ); + }, +); + const FileLink = styled.a` margin-bottom: 20px; font-weight: normal; @@ -102,6 +139,22 @@ function isMultiple(value) { return Array.isArray(value) || List.isList(value); } +function sizeOfValue(value) { + if (Array.isArray(value)) { + return value.length; + } + + if (List.isList(value)) { + return value.size; + } + + return value ? 1 : 0; +} + +function valueListToArray(value) { + return List.isList(value) ? value.toArray() : value; +} + const warnDeprecatedOptions = once(field => console.warn(oneLine` Netlify CMS config: ${field.get('name')} field: property "options" has been deprecated for the @@ -178,26 +231,13 @@ export default function withFileControl({ forImage } = {}) { handleChange = e => { const { field, onOpenMediaLibrary, value } = this.props; e.preventDefault(); - let mediaLibraryFieldOptions; - - /** - * `options` hash as a general field property is deprecated, only used - * when external media libraries were first introduced. Not to be - * confused with `options` for the select widget, which serves a different - * purpose. - */ - if (field.hasIn(['options', 'media_library'])) { - warnDeprecatedOptions(field); - mediaLibraryFieldOptions = field.getIn(['options', 'media_library'], Map()); - } else { - mediaLibraryFieldOptions = field.get('media_library', Map()); - } + const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); return onOpenMediaLibrary({ controlID: this.controlID, forImage, privateUpload: field.get('private'), - value, + value: valueListToArray(value), allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true), config: mediaLibraryFieldOptions.get('config'), field, @@ -218,6 +258,47 @@ export default function withFileControl({ forImage } = {}) { return this.props.onChange(''); }; + onRemoveOne = index => () => { + const { value } = this.props; + value.splice(index, 1); + return this.props.onChange(sizeOfValue(value) > 0 ? [...value] : null); + }; + + onReplaceOne = index => () => { + const { field, onOpenMediaLibrary, value } = this.props; + const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); + + return onOpenMediaLibrary({ + controlID: this.controlID, + forImage, + privateUpload: field.get('private'), + value: valueListToArray(value), + replaceIndex: index, + allowMultiple: false, + config: mediaLibraryFieldOptions.get('config'), + field, + }); + }; + + getMediaLibraryFieldOptions = () => { + const { field } = this.props; + + if (field.hasIn(['options', 'media_library'])) { + warnDeprecatedOptions(field); + return field.getIn(['options', 'media_library'], Map()); + } + + return field.get('media_library', Map()); + }; + + allowsMultiple = () => { + const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); + return ( + mediaLibraryFieldOptions.get('config', false) && + mediaLibraryFieldOptions.get('config').get('multiple', false) + ); + }; + onSortEnd = ({ oldIndex, newIndex }) => { const { value } = this.props; const newValue = arrayMove(value, oldIndex, newIndex); @@ -274,6 +355,9 @@ export default function withFileControl({ forImage } = {}) { { const { t, field } = this.props; + const allowsMultiple = this.allowsMultiple(); return (
{forImage ? this.renderImages() : null}
{forImage ? null : this.renderFileLinks()} - {t(`editor.editorWidgets.${subject}.chooseDifferent`)} + {t( + `editor.editorWidgets.${subject}.${ + this.allowsMultiple() ? 'addMore' : 'chooseDifferent' + }`, + )} - {field.get('choose_url', true) ? ( + {field.get('choose_url', true) && !this.allowsMultiple() ? ( {t(`editor.editorWidgets.${subject}.replaceUrl`)} ) : null} - {t(`editor.editorWidgets.${subject}.remove`)} + {t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)}
@@ -318,7 +407,7 @@ export default function withFileControl({ forImage } = {}) { return ( <> - {t(`editor.editorWidgets.${subject}.choose`)} + {t(`editor.editorWidgets.${subject}.choose${this.allowsMultiple() ? 'Multiple' : ''}`)} {field.get('choose_url', true) ? (