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 label: File
file: _widgets/file.json file: _widgets/file.json
description: File widget description: File widget
media_library:
folder_support: false
fields: fields:
- name: required - name: required
label: Required Validation label: Required Validation
@ -287,6 +289,22 @@ collections:
widget: file widget: file
required: false required: false
choose_url: true 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 - name: image
label: Image label: Image
file: _widgets/image.json file: _widgets/image.json
@ -311,6 +329,17 @@ collections:
widget: image widget: image
required: false required: false
choose_url: true 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 - name: folder_support
label: Folder Support label: Folder Support
widget: image widget: image

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ export default function useMediaInsert<T extends string | string[], F extends Me
insertOptions?: MediaLibrarInsertOptions; insertOptions?: MediaLibrarInsertOptions;
}, },
callback: (newValue: MediaPath<T>) => void, callback: (newValue: MediaPath<T>) => void,
): (e?: MouseEvent) => void { ): (e?: MouseEvent, options?: { replaceIndex?: number }) => void {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { const {
@ -59,7 +59,7 @@ export default function useMediaInsert<T extends string | string[], F extends Me
controlID: finalControlID, controlID: finalControlID,
forImage, forImage,
forFolder, forFolder,
value: value.path, value: Array.isArray(value.path) ? [...value.path] : value.path,
alt: value.alt, alt: value.alt,
replaceIndex, replaceIndex,
allowMultiple: false, 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)) { if (Array.isArray(value)) {
return ( return (
<div> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{value.map(link => ( {value.map((link, index) => (
<FileLink key={link} value={link} collection={collection} field={field} entry={entry} /> <FileLink
key={`link-preview-${index}`}
value={link}
collection={collection}
field={field}
entry={entry}
/>
))} ))}
</div> </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 { CameraAlt as CameraAltIcon } from '@styled-icons/material/CameraAlt';
import { Close as CloseIcon } from '@styled-icons/material/Close'; import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton'; import IconButton from '@staticcms/core/components/common/button/IconButton';
import Image from '@staticcms/core/components/common/image/Image'; 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'; import type { FC, MouseEventHandler } from 'react';
export interface SortableImageProps { export interface SortableImageProps {
id: string;
itemValue: string; itemValue: string;
collection: Collection<FileOrImageField>; collection: Collection<FileOrImageField>;
field: FileOrImageField; field: FileOrImageField;
onRemove: MouseEventHandler; onRemove?: MouseEventHandler;
onReplace: MouseEventHandler; onReplace?: MouseEventHandler;
} }
const SortableImage: FC<SortableImageProps> = ({ const SortableImage: FC<SortableImageProps> = ({
id,
itemValue, itemValue,
collection, collection,
field, field,
onRemove, onRemove,
onReplace, 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 ( return (
<div> <div
<div key="image-wrapper"> ref={setNodeRef}
{/* TODO $sortable */} style={style}
<Image key="image" src={itemValue} collection={collection} field={field} /> {...attributes}
</div> {...listeners}
<div key="image-buttons-wrapper"> className="
<IconButton key="image-replace" onClick={onReplace}> relative
<CameraAltIcon key="image-replace-icon" className="h-5 w-5" /> w-image-card
</IconButton> h-image-card
<IconButton key="image-remove" onClick={onRemove}> "
<CloseIcon key="image-remove-icon" className="h-5 w-5" /> tabIndex={-1}
</IconButton> 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> </div>
</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 React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import Button from '@staticcms/core/components/common/button/Button'; import Button from '@staticcms/core/components/common/button/Button';
import Field from '@staticcms/core/components/common/field/Field'; 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 useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename } from '@staticcms/core/lib/util'; 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 { isEmpty } from '@staticcms/core/lib/util/string.util';
import SortableImage from './components/SortableImage'; import SortableImage from './components/SortableImage';
import SortableLink from './components/SortableLink';
import type { import type { DragEndEvent } from '@dnd-kit/core';
BaseField, import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
Collection,
FileOrImageField,
MediaPath,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react'; import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50; const MAX_DISPLAY_LENGTH = 50;
@ -33,6 +40,11 @@ export function getValidFileValue(value: string | string[] | null | undefined) {
return value; return value;
} }
export interface FileControlState {
keys: string[];
internalRawValue: string | string[];
}
export interface WithFileControlProps { export interface WithFileControlProps {
forImage?: boolean; forImage?: boolean;
} }
@ -48,16 +60,31 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
forSingleList, forSingleList,
duplicate, duplicate,
onChange, onChange,
openMediaLibrary,
hasErrors, hasErrors,
disabled, disabled,
t, t,
}) => { }) => {
const controlID = useUUID(); 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( const internalValue = useMemo(
() => (duplicate ? value ?? '' : internalRawValue), () => (duplicate ? value ?? emptyValue : internalRawValue),
[internalRawValue, duplicate, value], [duplicate, value, emptyValue, internalRawValue],
); );
const uploadButtonRef = useRef<HTMLButtonElement | null>(null); 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 forFolder = useMemo(() => field.select_folder ?? false, [field.select_folder]);
const handleOnChange = useCallback( const handleOnChange = useCallback(
({ path: newValue }: MediaPath) => { ({ path: newValue }: MediaPath, providedNewKeys?: string[]) => {
if (newValue !== internalValue) { 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(() => { setTimeout(() => {
onChange(newValue); onChange(newValue);
}); });
} }
}, },
[internalValue, onChange], [internalValue, keys, onChange],
); );
const handleOpenMediaLibrary = useMediaInsert( const handleOpenMediaLibrary = useMediaInsert(
{ path: internalValue }, { path: internalValue },
{ collection, field, controlID, forImage, forFolder }, {
collection,
field,
controlID,
forImage,
forFolder,
insertOptions: {
chooseUrl: field.choose_url,
},
},
handleOnChange, handleOnChange,
); );
const allowsMultiple = useMemo(() => {
return field.multiple ?? false;
}, [field.multiple]);
const chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]); const chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]);
const handleUrl = useCallback( const handleUrl = useCallback(
(subject: 'image' | 'folder' | 'file') => (e: MouseEvent) => { (subject: 'image' | 'folder' | 'file') => (e: MouseEvent) => {
e.preventDefault(); 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( const handleRemove = useCallback(
@ -112,40 +161,42 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
(index: number) => () => { (index: number) => () => {
if (Array.isArray(internalValue)) { if (Array.isArray(internalValue)) {
const newValue = [...internalValue]; const newValue = [...internalValue];
const newKeys = [...keys];
newValue.splice(index, 1); newValue.splice(index, 1);
handleOnChange({ path: newValue }); newKeys.splice(index, 1);
handleOnChange({ path: newValue }, newKeys);
} }
}, },
[handleOnChange, internalValue], [handleOnChange, internalValue, keys],
); );
const onReplaceOne = useCallback( const onReplaceOne = useCallback(
(index: number) => () => { (replaceIndex: number) => (e: MouseEvent) => {
return openMediaLibrary({ handleOpenMediaLibrary(e, { replaceIndex });
controlID,
forImage,
forFolder,
value: internalValue,
replaceIndex: index,
allowMultiple: false,
config: field.media_library,
collection: collection as Collection<BaseField>,
field,
});
}, },
[openMediaLibrary, controlID, internalValue, collection, field, forFolder], [handleOpenMediaLibrary],
); );
// TODO Readd when multiple uploads is supported const sensors = useSensors(
// const onSortEnd = useCallback( useSensor(PointerSensor),
// ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => { useSensor(KeyboardSensor, {
// if (Array.isArray(internalValue)) { coordinateGetter: sortableKeyboardCoordinates,
// const newValue = arrayMoveImmutable(internalValue, oldIndex, newIndex); }),
// handleOnChange(newValue); );
// }
// }, const onSortEnd = useCallback(
// [handleOnChange, internalValue], ({ 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( const renderFileLink = useCallback(
(link: string | undefined | null) => { (link: string | undefined | null) => {
@ -177,18 +228,31 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (isMultiple(internalValue)) { if (isMultiple(internalValue)) {
return ( return (
<div key="multi-image-wrapper"> <DndContext
{internalValue.map((itemValue, index) => ( key="multi-image-wrapper"
<SortableImage sensors={sensors}
key={`item-${itemValue}`} collisionDetection={closestCenter}
itemValue={itemValue} onDragEnd={onSortEnd}
collection={collection} >
field={field} <SortableContext items={keys} strategy={rectSortingStrategy}>
onRemove={onRemoveOne(index)} <div className="grid grid-cols-images gap-2">
onReplace={onReplaceOne(index)} {internalValue.map((itemValue, index) => {
/> const key = keys[index];
))} return (
</div> <SortableImage
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)) { if (isMultiple(internalValue)) {
return ( return (
<div key="mulitple-file-links"> <DndContext
<ul key="file-links-list"> key="multi-image-wrapper"
{internalValue.map(val => ( sensors={sensors}
<li key={val}>{renderFileLink(val)}</li> collisionDetection={closestCenter}
))} onDragEnd={onSortEnd}
</ul> >
</div> <SortableContext items={keys} strategy={verticalListSortingStrategy}>
<div key="mulitple-file-links">
{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>; 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 content: JSX.Element = useMemo(() => {
const subject = forImage ? 'image' : forFolder ? 'folder' : 'file'; const subject = forImage ? 'image' : forFolder ? 'folder' : 'file';
@ -250,7 +340,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
} }
return ( 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} {renderedImagesLinks}
<div key="controls" className="flex gap-2"> <div key="controls" className="flex gap-2">
<Button <Button
@ -268,17 +364,30 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}`, }`,
)} )}
</Button> </Button>
{chooseUrl && !allowsMultiple ? ( {chooseUrl ? (
<Button allowsMultiple ? (
color="primary" <Button
variant="outlined" color="primary"
key="replace-url" variant="outlined"
onClick={handleUrl(subject)} key="choose-url"
data-testid="replace-url" onClick={handleUrl(subject)}
disabled={disabled} data-testid="choose-url"
> disabled={disabled}
{t(`editor.editorWidgets.${subject}.replaceUrl`)} >
</Button> {t(`editor.editorWidgets.${subject}.chooseUrl`)}
</Button>
) : (
<Button
color="primary"
variant="outlined"
key="replace-url"
onClick={handleUrl(subject)}
data-testid="replace-url"
disabled={disabled}
>
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
</Button>
)
) : null} ) : null}
<Button <Button
color="error" color="error"
@ -296,11 +405,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}, [ }, [
forFolder, forFolder,
internalValue, internalValue,
allowsMultiple,
renderedImagesLinks, renderedImagesLinks,
handleOpenMediaLibrary, handleOpenMediaLibrary,
disabled, disabled,
t, t,
allowsMultiple,
chooseUrl, chooseUrl,
handleUrl, handleUrl,
handleRemove, handleRemove,
@ -309,19 +418,19 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return useMemo( return useMemo(
() => ( () => (
<Field <Field
inputRef={uploadButtonRef} inputRef={allowsMultiple ? undefined : uploadButtonRef}
label={label} label={label}
errors={errors} errors={errors}
noPadding={!hasErrors} noPadding={!hasErrors}
hint={field.hint} hint={field.hint}
forSingleList={forSingleList} forSingleList={forSingleList}
cursor="pointer" cursor={allowsMultiple ? 'default' : 'pointer'}
disabled={disabled} disabled={disabled}
> >
{content} {content}
</Field> </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)) { if (Array.isArray(value)) {
return ( return (
<> <>
{value.map(val => ( {value.map((val, index) => (
<ImageAsset key={val} value={val} collection={collection} field={field} entry={entry} /> <ImageAsset
key={`image-preview-${index}`}
value={val}
collection={collection}
field={field}
entry={entry}
/>
))} ))}
</> </>
); );

View File

@ -269,6 +269,8 @@ collections:
label: File label: File
file: _widgets/file.json file: _widgets/file.json
description: File widget description: File widget
media_library:
folder_support: false
fields: fields:
- name: required - name: required
label: Required Validation label: Required Validation
@ -287,6 +289,22 @@ collections:
widget: file widget: file
required: false required: false
choose_url: true 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 - name: image
label: Image label: Image
file: _widgets/image.json file: _widgets/image.json
@ -311,6 +329,17 @@ collections:
widget: image widget: image
required: false required: false
choose_url: true 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 - name: folder_support
label: Folder Support label: Folder Support
widget: image widget: image
@ -525,6 +554,42 @@ collections:
widget: markdown widget: markdown
media_library: media_library:
folder_support: true 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 - name: number
label: Number label: Number
file: _widgets/number.json 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) | | 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 | | 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. | | 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 ## Examples

View File

@ -17,10 +17,11 @@ For common options, see [Common widget options](/docs/widgets#common-widget-opti
| Name | Type | Default | Description | | Name | Type | Default | Description |
| ------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| default | string | `null` | _Optional_. The default value for the field. Accepts a string. | | 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 | | 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 files uploaded by the media library will be accessed, relative to the base of the built site | | 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) | | 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 | | 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 ## Example

View File

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