feat(image-widget): media library gallery tools (#6087) (#6236)

Co-authored-by: Erez Rokah <erezrokah@users.noreply.github.com>
This commit is contained in:
Anze Demsar 2022-03-25 15:39:20 +01:00 committed by GitHub
parent 1e53d35db9
commit 80c577a462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 163 additions and 43 deletions

View File

@ -413,6 +413,7 @@ function mediaLibraryOpened(payload: {
forImage?: boolean; forImage?: boolean;
privateUpload?: boolean; privateUpload?: boolean;
value?: string; value?: string;
replaceIndex?: number;
allowMultiple?: boolean; allowMultiple?: boolean;
config?: Map<string, unknown>; config?: Map<string, unknown>;
field?: EntryField; field?: EntryField;

View File

@ -45,6 +45,8 @@ const defaultState: {
files?: MediaFile[]; files?: MediaFile[];
config: Map<string, unknown>; config: Map<string, unknown>;
field?: EntryField; field?: EntryField;
value?: string | string[];
replaceIndex?: number;
} = { } = {
isVisible: false, isVisible: false,
showMediaButton: true, showMediaButton: true,
@ -62,7 +64,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
}); });
case MEDIA_LIBRARY_OPEN: { 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 libConfig = config || Map();
const privateUploadChanged = state.get('privateUpload') !== privateUpload; const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) { if (privateUploadChanged) {
@ -76,6 +79,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
controlMedia: Map(), controlMedia: Map(),
displayURLs: Map(), displayURLs: Map(),
field, field,
value,
replaceIndex,
}); });
} }
return state.withMutations(map => { return state.withMutations(map => {
@ -86,6 +91,8 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
map.set('privateUpload', privateUpload); map.set('privateUpload', privateUpload);
map.set('config', libConfig); map.set('config', libConfig);
map.set('field', field); 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: { case MEDIA_INSERT: {
const { mediaPath } = action.payload; const { mediaPath } = action.payload;
const controlID = state.get('controlID'); 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 => { return state.withMutations(map => {
map.setIn(['controlMedia', controlID], mediaPath); map.setIn(['controlMedia', controlID], valueArray);
}); });
} }

View File

@ -169,19 +169,25 @@ const en = {
}, },
image: { image: {
choose: 'Choose an image', choose: 'Choose an image',
chooseMultiple: 'Choose images',
chooseUrl: 'Insert from URL', chooseUrl: 'Insert from URL',
replaceUrl: 'Replace with URL', replaceUrl: 'Replace with URL',
promptUrl: 'Enter the URL of the image', promptUrl: 'Enter the URL of the image',
chooseDifferent: 'Choose different image', chooseDifferent: 'Choose different image',
addMore: 'Add more images',
remove: 'Remove image', remove: 'Remove image',
removeAll: 'Remove all images',
}, },
file: { file: {
choose: 'Choose a file', choose: 'Choose a file',
chooseUrl: 'Insert from URL', chooseUrl: 'Insert from URL',
chooseMultiple: 'Choose files',
replaceUrl: 'Replace with URL', replaceUrl: 'Replace with URL',
promptUrl: 'Enter the URL of the file', promptUrl: 'Enter the URL of the file',
chooseDifferent: 'Choose different file', chooseDifferent: 'Choose different file',
addMore: 'Add more files',
remove: 'Remove file', remove: 'Remove file',
removeAll: 'Remove all files',
}, },
unknownControl: { unknownControl: {
noControl: "No control for widget '%{widget}'.", noControl: "No control for widget '%{widget}'.",

View File

@ -7,7 +7,15 @@ import { Map, List } from 'immutable';
import { once } from 'lodash'; import { once } from 'lodash';
import uuid from 'uuid/v4'; import uuid from 'uuid/v4';
import { oneLine } from 'common-tags'; 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 { basename } from 'netlify-cms-lib-util';
import { SortableContainer, SortableElement } from 'react-sortable-hoc'; import { SortableContainer, SortableElement } from 'react-sortable-hoc';
import { arrayMoveImmutable as arrayMove } from 'array-move'; import { arrayMoveImmutable as arrayMove } from 'array-move';
@ -28,6 +36,15 @@ const ImageWrapper = styled.div`
cursor: ${props => (props.sortable ? 'pointer' : 'auto')}; 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` const StyledImage = styled.img`
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -38,35 +55,55 @@ function Image(props) {
return <StyledImage role="presentation" {...props} />; return <StyledImage role="presentation" {...props} />;
} }
const SortableImage = SortableElement(({ itemValue, getAsset, field }) => { function SortableImageButtons({ onRemove, onReplace }) {
return ( return (
<ImageWrapper sortable> <SortableImageButtonsWrapper>
<Image src={getAsset(itemValue, field) || ''} /> <IconButton size="small" type="media" onClick={onReplace}></IconButton>
</ImageWrapper> <IconButton size="small" type="close" onClick={onRemove}></IconButton>
</SortableImageButtonsWrapper>
); );
}); }
const SortableMultiImageWrapper = SortableContainer(({ items, getAsset, field }) => { const SortableImage = SortableElement(({ itemValue, getAsset, field, onRemove, onReplace }) => {
return ( return (
<div <div>
css={css` <ImageWrapper sortable>
display: flex; <Image src={getAsset(itemValue, field) || ''} />
flex-wrap: wrap; </ImageWrapper>
`} <SortableImageButtons
> item={itemValue}
{items.map((itemValue, index) => ( onRemove={onRemove}
<SortableImage onReplace={onReplace}
key={`item-${itemValue}`} ></SortableImageButtons>
index={index}
itemValue={itemValue}
getAsset={getAsset}
field={field}
/>
))}
</div> </div>
); );
}); });
const SortableMultiImageWrapper = SortableContainer(
({ items, getAsset, field, onRemoveOne, onReplaceOne }) => {
return (
<div
css={css`
display: flex;
flex-wrap: wrap;
`}
>
{items.map((itemValue, index) => (
<SortableImage
key={`item-${itemValue}`}
index={index}
itemValue={itemValue}
getAsset={getAsset}
field={field}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
</div>
);
},
);
const FileLink = styled.a` const FileLink = styled.a`
margin-bottom: 20px; margin-bottom: 20px;
font-weight: normal; font-weight: normal;
@ -102,6 +139,22 @@ function isMultiple(value) {
return Array.isArray(value) || List.isList(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 => const warnDeprecatedOptions = once(field =>
console.warn(oneLine` console.warn(oneLine`
Netlify CMS config: ${field.get('name')} field: property "options" has been deprecated for the 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 => { handleChange = e => {
const { field, onOpenMediaLibrary, value } = this.props; const { field, onOpenMediaLibrary, value } = this.props;
e.preventDefault(); e.preventDefault();
let mediaLibraryFieldOptions; const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions();
/**
* `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());
}
return onOpenMediaLibrary({ return onOpenMediaLibrary({
controlID: this.controlID, controlID: this.controlID,
forImage, forImage,
privateUpload: field.get('private'), privateUpload: field.get('private'),
value, value: valueListToArray(value),
allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true), allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true),
config: mediaLibraryFieldOptions.get('config'), config: mediaLibraryFieldOptions.get('config'),
field, field,
@ -218,6 +258,47 @@ export default function withFileControl({ forImage } = {}) {
return this.props.onChange(''); 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 }) => { onSortEnd = ({ oldIndex, newIndex }) => {
const { value } = this.props; const { value } = this.props;
const newValue = arrayMove(value, oldIndex, newIndex); const newValue = arrayMove(value, oldIndex, newIndex);
@ -274,6 +355,9 @@ export default function withFileControl({ forImage } = {}) {
<SortableMultiImageWrapper <SortableMultiImageWrapper
items={value} items={value}
onSortEnd={this.onSortEnd} onSortEnd={this.onSortEnd}
onRemoveOne={this.onRemoveOne}
onReplaceOne={this.onReplaceOne}
distance={4}
getAsset={getAsset} getAsset={getAsset}
field={field} field={field}
axis="xy" axis="xy"
@ -292,21 +376,26 @@ export default function withFileControl({ forImage } = {}) {
renderSelection = subject => { renderSelection = subject => {
const { t, field } = this.props; const { t, field } = this.props;
const allowsMultiple = this.allowsMultiple();
return ( return (
<div> <div>
{forImage ? this.renderImages() : null} {forImage ? this.renderImages() : null}
<div> <div>
{forImage ? null : this.renderFileLinks()} {forImage ? null : this.renderFileLinks()}
<FileWidgetButton onClick={this.handleChange}> <FileWidgetButton onClick={this.handleChange}>
{t(`editor.editorWidgets.${subject}.chooseDifferent`)} {t(
`editor.editorWidgets.${subject}.${
this.allowsMultiple() ? 'addMore' : 'chooseDifferent'
}`,
)}
</FileWidgetButton> </FileWidgetButton>
{field.get('choose_url', true) ? ( {field.get('choose_url', true) && !this.allowsMultiple() ? (
<FileWidgetButton onClick={this.handleUrl(subject)}> <FileWidgetButton onClick={this.handleUrl(subject)}>
{t(`editor.editorWidgets.${subject}.replaceUrl`)} {t(`editor.editorWidgets.${subject}.replaceUrl`)}
</FileWidgetButton> </FileWidgetButton>
) : null} ) : null}
<FileWidgetButtonRemove onClick={this.handleRemove}> <FileWidgetButtonRemove onClick={this.handleRemove}>
{t(`editor.editorWidgets.${subject}.remove`)} {t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)}
</FileWidgetButtonRemove> </FileWidgetButtonRemove>
</div> </div>
</div> </div>
@ -318,7 +407,7 @@ export default function withFileControl({ forImage } = {}) {
return ( return (
<> <>
<FileWidgetButton onClick={this.handleChange}> <FileWidgetButton onClick={this.handleChange}>
{t(`editor.editorWidgets.${subject}.choose`)} {t(`editor.editorWidgets.${subject}.choose${this.allowsMultiple() ? 'Multiple' : ''}`)}
</FileWidgetButton> </FileWidgetButton>
{field.get('choose_url', true) ? ( {field.get('choose_url', true) ? (
<FileWidgetButton onClick={this.handleUrl(subject)}> <FileWidgetButton onClick={this.handleUrl(subject)}>