From cf1e8c92a42c503df1a1f072d8438aee6bded06c Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Fri, 5 May 2023 17:11:59 -0400 Subject: [PATCH] feat: multi image support (#778) --- packages/core/dev-test/config.yml | 29 ++ .../src/components/common/button/Button.tsx | 1 + .../common/text-field/TextField.tsx | 1 + .../src/components/entry-editor/Editor.tsx | 12 +- .../common/CurrentMediaDetails.tsx | 27 +- .../media-library/common/MediaLibrary.tsx | 2 + packages/core/src/lib/hooks/useMediaInsert.ts | 4 +- packages/core/src/lib/util/dnd.util.ts | 53 ++++ .../core/src/widgets/file/FilePreview.tsx | 12 +- .../widgets/file/components/SortableImage.tsx | 173 +++++++++-- .../widgets/file/components/SortableLink.tsx | 138 +++++++++ .../core/src/widgets/file/withFileControl.tsx | 275 ++++++++++++------ .../core/src/widgets/image/ImagePreview.tsx | 10 +- packages/demo/public/config.yml | 65 +++++ packages/docs/content/docs/widget-file.mdx | 1 + packages/docs/content/docs/widget-image.mdx | 5 +- tailwind.base.config.js | 47 +-- 17 files changed, 706 insertions(+), 149 deletions(-) create mode 100644 packages/core/src/lib/util/dnd.util.ts create mode 100644 packages/core/src/widgets/file/components/SortableLink.tsx diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index c5ba97d5..ec7b3472 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -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 diff --git a/packages/core/src/components/common/button/Button.tsx b/packages/core/src/components/common/button/Button.tsx index 3bdb8693..f73c9cf5 100644 --- a/packages/core/src/components/common/button/Button.tsx +++ b/packages/core/src/components/common/button/Button.tsx @@ -118,6 +118,7 @@ const Button: FC = ({ type="button" role="button" tabIndex={0} + data-no-dnd="true" > {content} diff --git a/packages/core/src/components/common/text-field/TextField.tsx b/packages/core/src/components/common/text-field/TextField.tsx index ffc846db..190860a7 100644 --- a/packages/core/src/components/common/text-field/TextField.tsx +++ b/packages/core/src/components/common/text-field/TextField.tsx @@ -60,6 +60,7 @@ const TextField: FC = ({ onChange={onChange} onClick={onClick} data-testid={dataTestId ?? `${type}-input`} + data-no-dnd="true" readOnly={readonly} disabled={disabled} startAdornment={startAdornment} diff --git a/packages/core/src/components/entry-editor/Editor.tsx b/packages/core/src/components/entry-editor/Editor.tsx index 2a9aa78b..3a976c4a 100644 --- a/packages/core/src/components/entry-editor/Editor.tsx +++ b/packages/core/src/components/entry-editor/Editor.tsx @@ -195,16 +195,8 @@ const Editor: FC> = ({ } 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) { diff --git a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx index ed9ceb6a..e8de0752 100644 --- a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx +++ b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx @@ -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 = ({ 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 = ({ {forImage ? ( {insertOptions?.showAlt ? ( diff --git a/packages/core/src/components/media-library/common/MediaLibrary.tsx b/packages/core/src/components/media-library/common/MediaLibrary.tsx index 9a2ae0db..0c3bbebd 100644 --- a/packages/core/src/components/media-library/common/MediaLibrary.tsx +++ b/packages/core/src/components/media-library/common/MediaLibrary.tsx @@ -94,6 +94,7 @@ const MediaLibrary: FC> = ({ value: initialValue, alt: initialAlt, insertOptions, + replaceIndex, } = useAppSelector(selectMediaLibraryState); const entry = useAppSelector(selectEditingDraft); @@ -485,6 +486,7 @@ const MediaLibrary: FC> = ({ alt={alt} insertOptions={insertOptions} forImage={forImage} + replaceIndex={replaceIndex} onUrlChange={handleURLChange} onAltChange={handleAltChange} /> diff --git a/packages/core/src/lib/hooks/useMediaInsert.ts b/packages/core/src/lib/hooks/useMediaInsert.ts index 2a3ded0d..bb1c3d9f 100644 --- a/packages/core/src/lib/hooks/useMediaInsert.ts +++ b/packages/core/src/lib/hooks/useMediaInsert.ts @@ -24,7 +24,7 @@ export default function useMediaInsert) => void, -): (e?: MouseEvent) => void { +): (e?: MouseEvent, options?: { replaceIndex?: number }) => void { const dispatch = useAppDispatch(); const { @@ -59,7 +59,7 @@ export default function useMediaInsert { + 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) => { + 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; +} diff --git a/packages/core/src/widgets/file/FilePreview.tsx b/packages/core/src/widgets/file/FilePreview.tsx index 684d3358..9dd0c16f 100644 --- a/packages/core/src/widgets/file/FilePreview.tsx +++ b/packages/core/src/widgets/file/FilePreview.tsx @@ -39,9 +39,15 @@ const FileContent: FC> = if (Array.isArray(value)) { return ( -
- {value.map(link => ( - +
+ {value.map((link, index) => ( + ))}
); diff --git a/packages/core/src/widgets/file/components/SortableImage.tsx b/packages/core/src/widgets/file/components/SortableImage.tsx index 40f886d3..8fa690c8 100644 --- a/packages/core/src/widgets/file/components/SortableImage.tsx +++ b/packages/core/src/widgets/file/components/SortableImage.tsx @@ -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; field: FileOrImageField; - onRemove: MouseEventHandler; - onReplace: MouseEventHandler; + onRemove?: MouseEventHandler; + onReplace?: MouseEventHandler; } const SortableImage: FC = ({ + 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 ( -
-
- {/* TODO $sortable */} - -
-
- - - - - - +
+
+
+
+
+ {onReplace ? ( + + + + ) : null} + {onRemove ? ( + + + + ) : null} +
+
+
+ +
); diff --git a/packages/core/src/widgets/file/components/SortableLink.tsx b/packages/core/src/widgets/file/components/SortableLink.tsx new file mode 100644 index 00000000..e180231f --- /dev/null +++ b/packages/core/src/widgets/file/components/SortableLink.tsx @@ -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 = ({ 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 ( +
+
+
+ {text} +
+ {onReplace ? ( + + + + ) : null} + {onRemove ? ( + + + + ) : null} +
+
+
+
+ ); +}; + +export default SortableLink; diff --git a/packages/core/src/widgets/file/withFileControl.tsx b/packages/core/src/widgets/file/withFileControl.tsx index 206a0224..d476215f 100644 --- a/packages/core/src/widgets/file/withFileControl.tsx +++ b/packages/core/src/widgets/file/withFileControl.tsx @@ -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(() => { + 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(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, - 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 ( -
- {internalValue.map((itemValue, index) => ( - - ))} -
+ + +
+ {internalValue.map((itemValue, index) => { + const key = keys[index]; + return ( + + ); + })} +
+
+
); } @@ -201,18 +265,44 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { if (isMultiple(internalValue)) { return ( -
-
    - {internalValue.map(val => ( -
  • {renderFileLink(val)}
  • - ))} -
-
+ + +
+ {internalValue.map((itemValue, index) => { + const key = keys[index]; + return ( + + ); + })} +
+
+
); } return
{renderFileLink(internalValue)}
; - }, [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 ( -
+
{renderedImagesLinks}
- {chooseUrl && !allowsMultiple ? ( - + {chooseUrl ? ( + allowsMultiple ? ( + + ) : ( + + ) ) : null}