feat: multi image support (#778)
This commit is contained in:
parent
95010a5cce
commit
cf1e8c92a4
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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) {
|
||||||
|
@ -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 ? (
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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,
|
||||||
|
53
packages/core/src/lib/util/dnd.util.ts
Normal file
53
packages/core/src/lib/util/dnd.util.ts
Normal 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;
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
138
packages/core/src/widgets/file/components/SortableLink.tsx
Normal file
138
packages/core/src/widgets/file/components/SortableLink.tsx
Normal 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;
|
@ -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],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user