Files
static-cms/core/src/widgets/file/withFileControl.tsx
Daniel Lautzenheiser 0655f5c382 Feature/update dnd (#190)
* Add prop types
* Remove react-sortable-hoc
2022-12-05 12:06:19 -05:00

505 lines
14 KiB
TypeScript

import CloseIcon from '@mui/icons-material/Close';
import PhotoIcon from '@mui/icons-material/Photo';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { borders, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename, transientOptions } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type {
FileOrImageField,
GetAssetFunction,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent, MouseEventHandler } from 'react';
const MAX_DISPLAY_LENGTH = 50;
const StyledFileControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledFileControlContentProps {
$collapsed: boolean;
}
const StyledFileControlContent = styled(
'div',
transientOptions,
)<StyledFileControlContentProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
const StyledSelection = styled('div')`
display: flex;
flex-direction: column;
`;
const StyledButtonWrapper = styled('div')`
display: flex;
gap: 16px;
`;
interface ImageWrapperProps {
$sortable?: boolean;
}
const ImageWrapper = styled(
'div',
transientOptions,
)<ImageWrapperProps>(
({ $sortable }) => `
flex-basis: 155px;
width: 155px;
height: 100px;
margin-right: 20px;
margin-bottom: 20px;
border: ${borders.textField};
border-radius: ${lengths.borderRadius};
overflow: hidden;
${effects.checkerboard};
${shadows.inset};
cursor: ${$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%;
object-fit: contain;
`;
interface ImageProps {
src: string;
}
const Image: FC<ImageProps> = ({ src }) => {
return <StyledImage key="image" role="presentation" src={src} />;
};
interface SortableImageButtonsProps {
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImageButtons: FC<SortableImageButtonsProps> = ({ onRemove, onReplace }) => {
return (
<SortableImageButtonsWrapper key="image-buttons-wrapper">
<IconButton key="image-replace" onClick={onReplace}>
<PhotoIcon key="image-replace-icon" />
</IconButton>
<IconButton key="image-remove" onClick={onRemove}>
<CloseIcon key="image-remove-icon" />
</IconButton>
</SortableImageButtonsWrapper>
);
};
interface SortableImageProps {
itemValue: string;
getAsset: GetAssetFunction<FileOrImageField>;
field: FileOrImageField;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
itemValue,
getAsset,
field,
onRemove,
onReplace,
}: SortableImageProps) => {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
const getImage = async () => {
const asset = (await getAsset(itemValue, field))?.toString() ?? '';
setAssetSource(asset);
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemValue]);
return (
<div>
<ImageWrapper key="image-wrapper" $sortable>
<Image key="image" src={assetSource} />
</ImageWrapper>
<SortableImageButtons
key="image-buttons"
onRemove={onRemove}
onReplace={onReplace}
></SortableImageButtons>
</div>
);
};
const StyledMultiImageWrapper = styled('div')`
display: flex;
flex-wrap: wrap;
`;
const FileLink = styled('a')`
margin-bottom: 20px;
font-weight: normal;
color: inherit;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
`;
const FileLinks = styled('div')`
margin-bottom: 12px;
`;
const FileLinkList = styled('ul')`
list-style-type: none;
`;
function isMultiple(value: string | string[] | null | undefined): value is string[] {
return Array.isArray(value);
}
export function getValidFileValue(value: string | string[] | null | undefined) {
if (value) {
return isMultiple(value) ? value.map(v => basename(v)) : basename(value);
}
return value;
}
export interface WithFileControlProps {
forImage?: boolean;
}
const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo(
({
value,
field,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
getAsset,
hasErrors,
t,
}) => {
const controlID = useUUID();
const [collapsed, setCollapsed] = useState(false);
const [internalValue, setInternalValue] = useState(value ?? '');
const handleOnChange = useCallback(
(newValue: string | string[]) => {
if (newValue !== internalValue) {
setInternalValue(newValue);
setTimeout(() => {
onChange(newValue);
});
}
},
[internalValue, onChange],
);
const handleOpenMediaLibrary = useMediaInsert(
internalValue,
{ field, controlID },
handleOnChange,
);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
useEffect(() => {
return () => {
removeMediaControl(controlID);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mediaLibraryFieldOptions = useMemo(() => {
return field.media_library ?? {};
}, [field.media_library]);
const config = useMemo(
() => ('config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined),
[mediaLibraryFieldOptions],
);
const allowsMultiple = useMemo(() => {
return config?.multiple ?? false;
}, [config?.multiple]);
const chooseUrl = useMemo(
() =>
'choose_url' in mediaLibraryFieldOptions && (mediaLibraryFieldOptions.choose_url ?? true),
[mediaLibraryFieldOptions],
);
const handleUrl = useCallback(
(subject: 'image' | 'file') => (e: MouseEvent) => {
e.preventDefault();
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`));
handleOnChange(url ?? '');
},
[handleOnChange, t],
);
const handleRemove = useCallback(
(e: MouseEvent) => {
e.preventDefault();
clearMediaControl(controlID);
handleOnChange('');
},
[clearMediaControl, controlID, handleOnChange],
);
const onRemoveOne = useCallback(
(index: number) => () => {
if (Array.isArray(internalValue)) {
const newValue = [...internalValue];
newValue.splice(index, 1);
handleOnChange(newValue);
}
},
[handleOnChange, internalValue],
);
const onReplaceOne = useCallback(
(index: number) => () => {
return openMediaLibrary({
controlID,
forImage,
value: internalValue,
replaceIndex: index,
allowMultiple: false,
config,
field,
});
},
[config, controlID, field, openMediaLibrary, internalValue],
);
// TODO Readd when multiple uploads is supported
// const onSortEnd = useCallback(
// ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
// if (Array.isArray(internalValue)) {
// const newValue = arrayMoveImmutable(internalValue, oldIndex, newIndex);
// handleOnChange(newValue);
// }
// },
// [handleOnChange, internalValue],
// );
const renderFileLink = useCallback((link: string | undefined | null) => {
const size = MAX_DISPLAY_LENGTH;
if (!link || link.length <= size) {
return link;
}
const text = `${link.slice(0, size / 2)}\u2026${link.slice(-(size / 2) + 1)}`;
return (
<FileLink key={`file-link-${text}`} href={link} rel="noopener" target="_blank">
{text}
</FileLink>
);
}, []);
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
if (Array.isArray(internalValue)) {
return;
}
const getImage = async () => {
const newValue = (await getAsset(internalValue, field))?.toString() ?? '';
if (newValue !== internalValue) {
setAssetSource(newValue);
}
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalValue]);
const renderedImagesLinks = useMemo(() => {
if (forImage) {
if (!internalValue) {
return null;
}
if (isMultiple(internalValue)) {
return (
<StyledMultiImageWrapper key="multi-image-wrapper">
{internalValue.map((itemValue, index) => (
<SortableImage
key={`item-${itemValue}`}
itemValue={itemValue}
getAsset={getAsset}
field={field}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
</StyledMultiImageWrapper>
);
}
return (
<ImageWrapper key="single-image-wrapper">
<Image key="single-image" src={assetSource} />
</ImageWrapper>
);
}
if (isMultiple(internalValue)) {
return (
<FileLinks key="mulitple-file-links">
<FileLinkList key="file-links-list">
{internalValue.map(val => (
<li key={val}>{renderFileLink(val)}</li>
))}
</FileLinkList>
</FileLinks>
);
}
return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>;
}, [assetSource, field, getAsset, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
const content = useMemo(() => {
const subject = forImage ? 'image' : 'file';
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return (
<StyledButtonWrapper>
<Button
color="primary"
variant="outlined"
key="upload"
onClick={handleOpenMediaLibrary}
>
{t(`editor.editorWidgets.${subject}.choose${allowsMultiple ? 'Multiple' : ''}`)}
</Button>
{chooseUrl ? (
<Button
color="primary"
variant="outlined"
key="choose-url"
onClick={handleUrl(subject)}
>
{t(`editor.editorWidgets.${subject}.chooseUrl`)}
</Button>
) : null}
</StyledButtonWrapper>
);
}
return (
<StyledSelection key="selection">
{renderedImagesLinks}
<StyledButtonWrapper key="controls">
<Button
color="primary"
variant="outlined"
key="add-replace"
onClick={handleOpenMediaLibrary}
>
{t(
`editor.editorWidgets.${subject}.${
allowsMultiple ? 'addMore' : 'chooseDifferent'
}`,
)}
</Button>
{chooseUrl && !allowsMultiple ? (
<Button
color="primary"
variant="outlined"
key="replace-url"
onClick={handleUrl(subject)}
>
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
</Button>
) : null}
<Button color="error" variant="outlined" key="remove" onClick={handleRemove}>
{t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)}
</Button>
</StyledButtonWrapper>
</StyledSelection>
);
}, [
internalValue,
renderedImagesLinks,
handleOpenMediaLibrary,
t,
allowsMultiple,
chooseUrl,
handleUrl,
handleRemove,
]);
return useMemo(
() => (
<StyledFileControlWrapper key="file-control-wrapper">
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledFileControlContent $collapsed={collapsed}>{content}</StyledFileControlContent>
<Outline hasError={hasErrors} />
</StyledFileControlWrapper>
),
[collapsed, content, field.label, field.name, handleCollapseToggle, hasErrors, t],
);
},
);
FileControl.displayName = 'FileControl';
return FileControl;
};
export default withFileControl;