feat: multi image support (#778)
This commit is contained in:
parent
95010a5cce
commit
cf1e8c92a4
@ -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
|
||||
|
@ -118,6 +118,7 @@ const Button: FC<ButtonLinkProps> = ({
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-no-dnd="true"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
@ -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}
|
||||
|
@ -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) {
|
||||
|
@ -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 &&
|
||||
!insertOptions?.showAlt &&
|
||||
(typeof url !== 'string' || isEmpty(url)))
|
||||
!Array.isArray(url) &&
|
||||
!insertOptions?.chooseUrl &&
|
||||
!insertOptions?.showAlt &&
|
||||
(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 ? (
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
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)) {
|
||||
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>
|
||||
);
|
||||
|
@ -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>
|
||||
<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
|
||||
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>
|
||||
</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 { 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) => (
|
||||
<SortableImage
|
||||
key={`item-${itemValue}`}
|
||||
itemValue={itemValue}
|
||||
collection={collection}
|
||||
field={field}
|
||||
onRemove={onRemoveOne(index)}
|
||||
onReplace={onReplaceOne(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
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 (
|
||||
<div key="mulitple-file-links">
|
||||
<ul key="file-links-list">
|
||||
{internalValue.map(val => (
|
||||
<li key={val}>{renderFileLink(val)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DndContext
|
||||
key="multi-image-wrapper"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onSortEnd}
|
||||
>
|
||||
<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>;
|
||||
}, [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,17 +364,30 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
|
||||
}`,
|
||||
)}
|
||||
</Button>
|
||||
{chooseUrl && !allowsMultiple ? (
|
||||
<Button
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
key="replace-url"
|
||||
onClick={handleUrl(subject)}
|
||||
data-testid="replace-url"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
|
||||
</Button>
|
||||
{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"
|
||||
key="replace-url"
|
||||
onClick={handleUrl(subject)}
|
||||
data-testid="replace-url"
|
||||
disabled={disabled}
|
||||
>
|
||||
{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],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user