feat: multi image support (#778)

This commit is contained in:
Daniel Lautzenheiser 2023-05-05 17:11:59 -04:00 committed by GitHub
parent 95010a5cce
commit cf1e8c92a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 706 additions and 149 deletions

View File

@ -269,6 +269,8 @@ collections:
label: File
file: _widgets/file.json
description: File widget
media_library:
folder_support: false
fields:
- name: required
label: Required Validation
@ -287,6 +289,22 @@ collections:
widget: file
required: false
choose_url: true
- name: multiple
label: Multiple Files
widget: file
required: false
multiple: true
- name: multiple_choose_url
label: Multiple Files, Choose URL
widget: file
required: false
multiple: true
choose_url: true
- name: folder_support
label: Folder Support
widget: file
media_library:
folder_support: true
- name: image
label: Image
file: _widgets/image.json
@ -311,6 +329,17 @@ collections:
widget: image
required: false
choose_url: true
- name: multiple
label: Multiple Images
widget: image
required: false
multiple: true
- name: multiple_choose_url
label: Multiple Images, Choose URL
widget: image
required: false
multiple: true
choose_url: true
- name: folder_support
label: Folder Support
widget: image

View File

@ -118,6 +118,7 @@ const Button: FC<ButtonLinkProps> = ({
type="button"
role="button"
tabIndex={0}
data-no-dnd="true"
>
{content}
</button>

View File

@ -60,6 +60,7 @@ const TextField: FC<TextFieldProps> = ({
onChange={onChange}
onClick={onClick}
data-testid={dataTestId ?? `${type}-input`}
data-no-dnd="true"
readOnly={readonly}
disabled={disabled}
startAdornment={startAdornment}

View File

@ -195,16 +195,8 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
}
setPrevLocalBackup(localBackup);
}, [
config?.disable_local_backup,
deleteBackup,
dispatch,
entryDraft.entry?.data,
entryDraft.entry?.meta,
localBackup,
prevLocalBackup,
version,
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config?.disable_local_backup, deleteBackup, dispatch, localBackup, prevLocalBackup, version]);
useEffect(() => {
if (hasChanged && entryDraft.entry) {

View File

@ -1,6 +1,7 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { isEmpty } from '../../../lib/util/string.util';
import Image from '../../common/image/Image';
import InlineEditTextField from './InlineEditTextField';
@ -16,6 +17,7 @@ interface CurrentMediaDetailsProps {
alt?: string;
insertOptions?: MediaLibrarInsertOptions;
forImage: boolean;
replaceIndex?: number;
onUrlChange: (url: string) => void;
onAltChange: (alt: string) => void;
}
@ -28,16 +30,25 @@ const CurrentMediaDetails: FC<CurrentMediaDetailsProps> = ({
alt,
insertOptions,
forImage,
replaceIndex,
onUrlChange,
onAltChange,
}) => {
if (!field || !canInsert) {
return null;
}
if (Array.isArray(url)) {
if (isNullish(replaceIndex)) {
return null;
}
}
if (
!field ||
!canInsert ||
Array.isArray(url) ||
(!insertOptions?.chooseUrl &&
!Array.isArray(url) &&
!insertOptions?.chooseUrl &&
!insertOptions?.showAlt &&
(typeof url !== 'string' || isEmpty(url)))
(typeof url !== 'string' || isEmpty(url))
) {
return null;
}
@ -67,7 +78,7 @@ const CurrentMediaDetails: FC<CurrentMediaDetailsProps> = ({
{forImage ? (
<Image
key="image-preview"
src={url}
src={Array.isArray(url) ? url[replaceIndex!] : url}
collection={collection}
field={field}
className="
@ -107,7 +118,7 @@ const CurrentMediaDetails: FC<CurrentMediaDetailsProps> = ({
>
<InlineEditTextField
label="URL"
value={url}
value={Array.isArray(url) ? url[replaceIndex!] : url}
onChange={insertOptions?.chooseUrl ? onUrlChange : undefined}
/>
{insertOptions?.showAlt ? (

View File

@ -94,6 +94,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
value: initialValue,
alt: initialAlt,
insertOptions,
replaceIndex,
} = useAppSelector(selectMediaLibraryState);
const entry = useAppSelector(selectEditingDraft);
@ -485,6 +486,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
alt={alt}
insertOptions={insertOptions}
forImage={forImage}
replaceIndex={replaceIndex}
onUrlChange={handleURLChange}
onAltChange={handleAltChange}
/>

View File

@ -24,7 +24,7 @@ export default function useMediaInsert<T extends string | string[], F extends Me
insertOptions?: MediaLibrarInsertOptions;
},
callback: (newValue: MediaPath<T>) => void,
): (e?: MouseEvent) => void {
): (e?: MouseEvent, options?: { replaceIndex?: number }) => void {
const dispatch = useAppDispatch();
const {
@ -59,7 +59,7 @@ export default function useMediaInsert<T extends string | string[], F extends Me
controlID: finalControlID,
forImage,
forFolder,
value: value.path,
value: Array.isArray(value.path) ? [...value.path] : value.path,
alt: value.alt,
replaceIndex,
allowMultiple: false,

View File

@ -0,0 +1,53 @@
import {
PointerSensor as LibPointerSensor,
MouseSensor as LibMouseSensor,
KeyboardSensor as LibKeyboardSensor,
} from '@dnd-kit/core';
import type { MouseEvent, KeyboardEvent, PointerEvent } from 'react';
export class PointerSensor extends LibPointerSensor {
static activators = [
{
eventName: 'onPointerDown' as const,
handler: ({ nativeEvent: event }: PointerEvent) => {
return shouldHandleEvent(event.target as HTMLElement);
},
},
];
}
export class MouseSensor extends LibMouseSensor {
static activators = [
{
eventName: 'onMouseDown' as const,
handler: ({ nativeEvent: event }: MouseEvent) => {
return shouldHandleEvent(event.target as HTMLElement);
},
},
];
}
export class KeyboardSensor extends LibKeyboardSensor {
static activators = [
{
eventName: 'onKeyDown' as const,
handler: ({ nativeEvent: event }: KeyboardEvent<Element>) => {
return shouldHandleEvent(event.target as HTMLElement);
},
},
];
}
function shouldHandleEvent(element: HTMLElement | null) {
let cur = element;
while (cur) {
if (cur.dataset && cur.dataset.noDnd) {
return false;
}
cur = cur.parentElement;
}
return true;
}

View File

@ -39,9 +39,15 @@ const FileContent: FC<WidgetPreviewProps<string | string[], FileOrImageField>> =
if (Array.isArray(value)) {
return (
<div>
{value.map(link => (
<FileLink key={link} value={link} collection={collection} field={field} entry={entry} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{value.map((link, index) => (
<FileLink
key={`link-preview-${index}`}
value={link}
collection={collection}
field={field}
entry={entry}
/>
))}
</div>
);

View File

@ -1,6 +1,8 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CameraAlt as CameraAltIcon } from '@styled-icons/material/CameraAlt';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import React from 'react';
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
import React, { useCallback, useMemo } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Image from '@staticcms/core/components/common/image/Image';
@ -9,33 +11,170 @@ import type { Collection, FileOrImageField } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react';
export interface SortableImageProps {
id: string;
itemValue: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
onRemove?: MouseEventHandler;
onReplace?: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
id,
itemValue,
collection,
field,
onRemove,
onReplace,
}: SortableImageProps) => {
}) => {
const sortableProps = useMemo(() => ({ id }), [id]);
const { attributes, listeners, setNodeRef, transform, transition } = useSortable(sortableProps);
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
}),
[transform, transition],
);
const handleClick: MouseEventHandler = useCallback(event => {
event.stopPropagation();
event.preventDefault();
}, []);
const handleReplace: MouseEventHandler = useCallback(
event => {
event.stopPropagation();
event.preventDefault();
onReplace?.(event);
},
[onReplace],
);
const handleRemove: MouseEventHandler = useCallback(
event => {
event.stopPropagation();
event.preventDefault();
onRemove?.(event);
},
[onRemove],
);
return (
<div>
<div key="image-wrapper">
{/* TODO $sortable */}
<Image key="image" src={itemValue} collection={collection} field={field} />
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="
relative
w-image-card
h-image-card
"
tabIndex={-1}
title={itemValue}
>
<div
onClick={handleClick}
data-testid={`image-card-${itemValue}`}
className="
w-image-card
h-image-card
rounded-md
shadow-sm
overflow-hidden
group/image-card
cursor-pointer
border
bg-gray-50/75
border-gray-200/75
dark:bg-slate-800
dark:border-slate-600/75
"
>
<div
key="handle"
data-testid={`image-card-handle-${itemValue}`}
tabIndex={0}
className="
absolute
inset-0
rounded-md
z-20
overflow-visible
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
focus-visible:outline-none
"
/>
<div
className="
absolute
inset-0
invisible
transition-all
rounded-md
group-hover/image-card:visible
group-hover/image-card:bg-blue-200/25
dark:group-hover/image-card:bg-blue-400/60
z-20
"
>
<div
className="
absolute
top-2
right-2
flex
gap-1
"
>
{onReplace ? (
<IconButton
key="replace"
variant="text"
onClick={handleReplace}
className="
text-white
dark:text-white
bg-gray-900/25
dark:hover:text-blue-100
dark:hover:bg-blue-800/80
"
>
<CameraAltIcon className="w-5 h-5" />
</IconButton>
) : null}
{onRemove ? (
<IconButton
key="remove"
variant="text"
color="error"
onClick={handleRemove}
className="
position: relative;
text-red-400
bg-gray-900/25
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30
"
>
<DeleteIcon className="w-5 h-5" />
</IconButton>
) : null}
</div>
</div>
<div className="relative">
<Image
src={itemValue}
className="w-image-card h-image-card rounded-md"
collection={collection}
field={field}
/>
</div>
<div key="image-buttons-wrapper">
<IconButton key="image-replace" onClick={onReplace}>
<CameraAltIcon key="image-replace-icon" className="h-5 w-5" />
</IconButton>
<IconButton key="image-remove" onClick={onRemove}>
<CloseIcon key="image-remove-icon" className="h-5 w-5" />
</IconButton>
</div>
</div>
);

View File

@ -0,0 +1,138 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ModeEdit as ModeEditIcon } from '@styled-icons/material/ModeEdit';
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
import React, { useCallback, useMemo } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import type { FC, MouseEventHandler } from 'react';
const MAX_DISPLAY_LENGTH = 100;
export interface SortableLinkProps {
id: string;
itemValue: string;
onRemove?: MouseEventHandler;
onReplace?: MouseEventHandler;
}
const SortableLink: FC<SortableLinkProps> = ({ id, itemValue, onRemove, onReplace }) => {
const sortableProps = useMemo(() => ({ id }), [id]);
const { attributes, listeners, setNodeRef, transform, transition } = useSortable(sortableProps);
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
}),
[transform, transition],
);
const handleClick: MouseEventHandler = useCallback(event => {
event.stopPropagation();
event.preventDefault();
}, []);
const handleReplace: MouseEventHandler = useCallback(
event => {
event.stopPropagation();
event.preventDefault();
onReplace?.(event);
},
[onReplace],
);
const handleRemove: MouseEventHandler = useCallback(
event => {
event.stopPropagation();
event.preventDefault();
onRemove?.(event);
},
[onRemove],
);
const text =
itemValue.length <= MAX_DISPLAY_LENGTH
? itemValue
: `${itemValue.slice(0, MAX_DISPLAY_LENGTH / 2)}\u2026${itemValue.slice(
-(MAX_DISPLAY_LENGTH / 2) + 1,
)}`;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="
relative
w-full
"
tabIndex={-1}
title={itemValue}
>
<div
onClick={handleClick}
data-testid={`image-card-${itemValue}`}
className="
w-full
shadow-sm
overflow-hidden
group/image-card
cursor-pointer
border-l-2
border-b
border-solid
border-l-slate-400
p-2
"
>
<div className="relative flex items-center justify-between">
<span>{text}</span>
<div
className="
flex
gap-1
"
>
{onReplace ? (
<IconButton
key="replace"
variant="text"
onClick={handleReplace}
className="
text-white
dark:text-white
dark:hover:text-blue-100
dark:hover:bg-blue-800/80
"
>
<ModeEditIcon className="w-5 h-5" />
</IconButton>
) : null}
{onRemove ? (
<IconButton
key="remove"
variant="text"
color="error"
onClick={handleRemove}
className="
position: relative;
text-red-400
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30
"
>
<DeleteIcon className="w-5 h-5" />
</IconButton>
) : null}
</div>
</div>
</div>
</div>
);
};
export default SortableLink;

View File

@ -1,4 +1,13 @@
import { DndContext, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import Button from '@staticcms/core/components/common/button/Button';
import Field from '@staticcms/core/components/common/field/Field';
@ -7,16 +16,14 @@ import Link from '@staticcms/core/components/common/link/Link';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import SortableImage from './components/SortableImage';
import SortableLink from './components/SortableLink';
import type {
BaseField,
Collection,
FileOrImageField,
MediaPath,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { DragEndEvent } from '@dnd-kit/core';
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50;
@ -33,6 +40,11 @@ export function getValidFileValue(value: string | string[] | null | undefined) {
return value;
}
export interface FileControlState {
keys: string[];
internalRawValue: string | string[];
}
export interface WithFileControlProps {
forImage?: boolean;
}
@ -48,16 +60,31 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
forSingleList,
duplicate,
onChange,
openMediaLibrary,
hasErrors,
disabled,
t,
}) => {
const controlID = useUUID();
const [internalRawValue, setInternalValue] = useState(value ?? '');
const allowsMultiple = useMemo(() => {
return field.multiple ?? false;
}, [field.multiple]);
const emptyValue = useMemo(() => (allowsMultiple ? [] : ''), [allowsMultiple]);
const [{ keys, internalRawValue }, setState] = useState<FileControlState>(() => {
const incomingValue = value ?? emptyValue;
return {
keys: Array.from(
{ length: Array.isArray(incomingValue) ? incomingValue.length : 1 },
() => uuid(),
),
internalRawValue: incomingValue,
};
});
const internalValue = useMemo(
() => (duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value],
() => (duplicate ? value ?? emptyValue : internalRawValue),
[duplicate, value, emptyValue, internalRawValue],
);
const uploadButtonRef = useRef<HTMLButtonElement | null>(null);
@ -65,38 +92,60 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const forFolder = useMemo(() => field.select_folder ?? false, [field.select_folder]);
const handleOnChange = useCallback(
({ path: newValue }: MediaPath) => {
({ path: newValue }: MediaPath, providedNewKeys?: string[]) => {
if (newValue !== internalValue) {
setInternalValue(newValue);
const newKeys = [...(providedNewKeys ?? keys)];
if (Array.isArray(newValue)) {
while (newKeys.length < newValue.length) {
newKeys.push(uuid());
}
}
setState({
keys: newKeys,
internalRawValue: newValue,
});
setTimeout(() => {
onChange(newValue);
});
}
},
[internalValue, onChange],
[internalValue, keys, onChange],
);
const handleOpenMediaLibrary = useMediaInsert(
{ path: internalValue },
{ collection, field, controlID, forImage, forFolder },
{
collection,
field,
controlID,
forImage,
forFolder,
insertOptions: {
chooseUrl: field.choose_url,
},
},
handleOnChange,
);
const allowsMultiple = useMemo(() => {
return field.multiple ?? false;
}, [field.multiple]);
const chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]);
const handleUrl = useCallback(
(subject: 'image' | 'folder' | 'file') => (e: MouseEvent) => {
e.preventDefault();
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`));
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`)) ?? '';
if (url === '') {
return;
}
handleOnChange({ path: url ?? '' });
handleOnChange({
path: allowsMultiple
? [...(Array.isArray(internalValue) ? internalValue : [internalValue]), url]
: url,
});
},
[handleOnChange, t],
[allowsMultiple, handleOnChange, internalValue, t],
);
const handleRemove = useCallback(
@ -112,40 +161,42 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
(index: number) => () => {
if (Array.isArray(internalValue)) {
const newValue = [...internalValue];
const newKeys = [...keys];
newValue.splice(index, 1);
handleOnChange({ path: newValue });
newKeys.splice(index, 1);
handleOnChange({ path: newValue }, newKeys);
}
},
[handleOnChange, internalValue],
[handleOnChange, internalValue, keys],
);
const onReplaceOne = useCallback(
(index: number) => () => {
return openMediaLibrary({
controlID,
forImage,
forFolder,
value: internalValue,
replaceIndex: index,
allowMultiple: false,
config: field.media_library,
collection: collection as Collection<BaseField>,
field,
});
(replaceIndex: number) => (e: MouseEvent) => {
handleOpenMediaLibrary(e, { replaceIndex });
},
[openMediaLibrary, controlID, internalValue, collection, field, forFolder],
[handleOpenMediaLibrary],
);
// 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 sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onSortEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (Array.isArray(internalValue) && over && active.id !== over.id) {
const oldIndex = keys.indexOf(`${active.id}`);
const newIndex = keys.indexOf(`${over.id}`);
const newKeys = arrayMove(keys, oldIndex, newIndex);
const newValue = arrayMove(internalValue, oldIndex, newIndex);
handleOnChange({ path: newValue }, newKeys);
}
},
[handleOnChange, internalValue, keys],
);
const renderFileLink = useCallback(
(link: string | undefined | null) => {
@ -177,18 +228,31 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (isMultiple(internalValue)) {
return (
<div key="multi-image-wrapper">
{internalValue.map((itemValue, index) => (
<DndContext
key="multi-image-wrapper"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onSortEnd}
>
<SortableContext items={keys} strategy={rectSortingStrategy}>
<div className="grid grid-cols-images gap-2">
{internalValue.map((itemValue, index) => {
const key = keys[index];
return (
<SortableImage
key={`item-${itemValue}`}
id={key}
key={`image-${key}`}
itemValue={itemValue}
collection={collection}
field={field}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
);
})}
</div>
</SortableContext>
</DndContext>
);
}
@ -201,18 +265,44 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (isMultiple(internalValue)) {
return (
<DndContext
key="multi-image-wrapper"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onSortEnd}
>
<SortableContext items={keys} strategy={verticalListSortingStrategy}>
<div key="mulitple-file-links">
<ul key="file-links-list">
{internalValue.map(val => (
<li key={val}>{renderFileLink(val)}</li>
))}
</ul>
{internalValue.map((itemValue, index) => {
const key = keys[index];
return (
<SortableLink
id={key}
key={`link-${key}`}
itemValue={itemValue}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
);
}
return <div key="single-file-links">{renderFileLink(internalValue)}</div>;
}, [collection, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
}, [
collection,
field,
internalValue,
keys,
onRemoveOne,
onReplaceOne,
onSortEnd,
renderFileLink,
sensors,
]);
const content: JSX.Element = useMemo(() => {
const subject = forImage ? 'image' : forFolder ? 'folder' : 'file';
@ -250,7 +340,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}
return (
<div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4">
<div
key="selection"
className={classNames(
`flex flex-col gap-4 pl-3 pt-2 pb-4`,
(forImage || !allowsMultiple) && 'pr-3',
)}
>
{renderedImagesLinks}
<div key="controls" className="flex gap-2">
<Button
@ -268,7 +364,19 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}`,
)}
</Button>
{chooseUrl && !allowsMultiple ? (
{chooseUrl ? (
allowsMultiple ? (
<Button
color="primary"
variant="outlined"
key="choose-url"
onClick={handleUrl(subject)}
data-testid="choose-url"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.chooseUrl`)}
</Button>
) : (
<Button
color="primary"
variant="outlined"
@ -279,6 +387,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
>
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
</Button>
)
) : null}
<Button
color="error"
@ -296,11 +405,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}, [
forFolder,
internalValue,
allowsMultiple,
renderedImagesLinks,
handleOpenMediaLibrary,
disabled,
t,
allowsMultiple,
chooseUrl,
handleUrl,
handleRemove,
@ -309,19 +418,19 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return useMemo(
() => (
<Field
inputRef={uploadButtonRef}
inputRef={allowsMultiple ? undefined : uploadButtonRef}
label={label}
errors={errors}
noPadding={!hasErrors}
hint={field.hint}
forSingleList={forSingleList}
cursor="pointer"
cursor={allowsMultiple ? 'default' : 'pointer'}
disabled={disabled}
>
{content}
</Field>
),
[content, disabled, errors, field.hint, forSingleList, hasErrors, label],
[content, disabled, errors, field.hint, allowsMultiple, forSingleList, hasErrors, label],
);
},
);

View File

@ -36,8 +36,14 @@ const ImagePreviewContent: FC<WidgetPreviewProps<string | string[], FileOrImageF
if (Array.isArray(value)) {
return (
<>
{value.map(val => (
<ImageAsset key={val} value={val} collection={collection} field={field} entry={entry} />
{value.map((val, index) => (
<ImageAsset
key={`image-preview-${index}`}
value={val}
collection={collection}
field={field}
entry={entry}
/>
))}
</>
);

View File

@ -269,6 +269,8 @@ collections:
label: File
file: _widgets/file.json
description: File widget
media_library:
folder_support: false
fields:
- name: required
label: Required Validation
@ -287,6 +289,22 @@ collections:
widget: file
required: false
choose_url: true
- name: multiple
label: Multiple Files
widget: file
required: false
multiple: true
- name: multiple_choose_url
label: Multiple Files, Choose URL
widget: file
required: false
multiple: true
choose_url: true
- name: folder_support
label: Folder Support
widget: file
media_library:
folder_support: true
- name: image
label: Image
file: _widgets/image.json
@ -311,6 +329,17 @@ collections:
widget: image
required: false
choose_url: true
- name: multiple
label: Multiple Images
widget: image
required: false
multiple: true
- name: multiple_choose_url
label: Multiple Images, Choose URL
widget: image
required: false
multiple: true
choose_url: true
- name: folder_support
label: Folder Support
widget: image
@ -525,6 +554,42 @@ collections:
widget: markdown
media_library:
folder_support: true
- name: customized_buttons
label: Customized Buttons
widget: markdown
toolbar_buttons:
main:
- bold
- italic
- font
- shortcode
- label: Insert
groups:
- items: ['image', 'file-link']
- items: ['insert-table']
empty:
- bold
- italic
- font
- label: Insert
groups:
- items: ['image', 'file-link']
- items: ['blockquote', 'code-block']
selection:
- bold
- italic
- font
- file-link
table_empty:
- insert-row
- insert-column
- delete-row
- delete-column
- delete-table
table_selection:
- bold
- italic
- font
- name: number
label: Number
file: _widgets/number.json

View File

@ -22,6 +22,7 @@ For common options, see [Common widget options](/docs/widgets#common-widget-opti
| media_library | Media Library Options | `{}` | _Optional_. Media library settings to apply when the media library is opened by the current widget. See [Media Library](/docs/configuration-options#media-library) |
| choose_url | boolean | `false` | _Optional_. When set to `false`, the "Insert from URL" button will be hidden |
| select_folder | boolean | `false` | _Optional_. When set to `true`, selecting folders instead of files will be possible. See [Media Library](/docs/configuration-options#media-library) for folder support. |
| multiple | boolean | `false` | _Optional_. When set to `true` multiple files are allowed in the widget |
## Examples

View File

@ -17,10 +17,11 @@ For common options, see [Common widget options](/docs/widgets#common-widget-opti
| Name | Type | Default | Description |
| ------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| default | string | `null` | _Optional_. The default value for the field. Accepts a string. |
| media_folder | string | | _Optional_. Specifies the folder path where uploaded files should be saved, relative to the base of the repo |
| public_folder | string | | _Optional_. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site |
| media_folder | string | | _Optional_. Specifies the folder path where uploaded images should be saved, relative to the base of the repo |
| public_folder | string | | _Optional_. Specifies the folder path where the image uploaded by the media library will be accessed, relative to the base of the built site |
| media_library | Media Library Options | `{}` | _Optional_. Media library settings to apply when the media library is opened by the current widget. See [Media Library](/docs/configuration-options#media-library) |
| choose_url | boolean | `false` | _Optional_. When set to `false`, the "Insert from URL" button will be hidden |
| multiple | boolean | `false` | _Optional_. When set to `true` multiple images are allowed in the widget |
## Example

View File

@ -1,41 +1,44 @@
module.exports = {
darkMode: 'class',
darkMode: "class",
theme: {
extend: {
height: {
main: 'calc(100vh - 64px)',
'media-library-dialog': '80vh',
'media-card': '240px',
'media-preview-image': '104px',
'media-card-image': '196px',
input: '24px',
main: "calc(100vh - 64px)",
"media-library-dialog": "80vh",
"media-card": "240px",
"media-preview-image": "104px",
"media-card-image": "196px",
"image-card": "120px",
input: "24px",
},
minHeight: {
8: '2rem',
'markdown-toolbar': '40px',
8: "2rem",
"markdown-toolbar": "40px",
},
width: {
main: 'calc(100% - 256px)',
preview: 'calc(100% - 450px)',
'sidebar-expanded': '256px',
'sidebar-collapsed': '68px',
'editor-only': '640px',
'media-library-dialog': '80vw',
'media-card': '240px',
'media-preview-image': '126px',
main: "calc(100% - 256px)",
preview: "calc(100% - 450px)",
"sidebar-expanded": "256px",
"sidebar-collapsed": "68px",
"editor-only": "640px",
"media-library-dialog": "80vw",
"media-card": "240px",
"media-preview-image": "126px",
"image-card": "120px",
},
maxWidth: {
'media-search': '400px',
"media-search": "400px",
},
boxShadow: {
sidebar: '0 10px 15px 18px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
sidebar: "0 10px 15px 18px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
gridTemplateColumns: {
editor: '450px auto',
'media-preview': '126px auto',
editor: "450px auto",
"media-preview": "126px auto",
images: "repeat(auto-fit, 120px)",
},
fontFamily: {
sans: ['Inter var', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
sans: ["Inter var", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
},
},
},