diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md new file mode 100644 index 00000000..81328ae4 --- /dev/null +++ b/BREAKING_CHANGES.md @@ -0,0 +1 @@ +- useMediaInsert now requires collection to be passed \ No newline at end of file diff --git a/packages/core/dev-test/backends/proxy/_data/settings.json b/packages/core/dev-test/backends/proxy/_data/settings.json index a3ff5a78..4b8b8e2f 100644 --- a/packages/core/dev-test/backends/proxy/_data/settings.json +++ b/packages/core/dev-test/backends/proxy/_data/settings.json @@ -1,9 +1,9 @@ { - "front_limit": 5, + "front_limit": 6, "site_title": "Test", "posts": { "front_limit": 4, "author": "Bob", "thumb": "/backends/proxy/assets/upload/kanefreeman_2.jpg" } -} +} \ No newline at end of file diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something.md b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/index.md similarity index 57% rename from packages/core/dev-test/backends/proxy/_posts/2022-11-01-something.md rename to packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/index.md index 625f939e..d29b700b 100644 --- a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something.md +++ b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/index.md @@ -2,7 +2,7 @@ title: Something something something... draft: false date: 2022-11-01 06:30 -image: /backends/proxy/assets/posts/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg +image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg --- # Welcome diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg new file mode 100644 index 00000000..fd99d1ea Binary files /dev/null and b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-something/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg differ diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test.md b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test.md deleted file mode 100644 index 69126dc1..00000000 --- a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Test -draft: false -date: 2022-11-01 14:28 -image: /backends/proxy/assets/posts/kittens.jpg ---- -Test2 - -
-![moby-dick.jpg](/assets/upload/moby-dick.jpg) -![moby-dick.jpg](/assets/upload/moby-dick.jpg) - -![kanefreeman_2.jpg](/assets/upload/kanefreeman_2.jpg) diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/index.md b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/index.md new file mode 100644 index 00000000..0e0dcf88 --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/index.md @@ -0,0 +1,11 @@ +--- +title: Test +draft: false +date: 2022-11-01 14:28 +image: kittens.jpg +--- +Test234t6 + +[Test](https://example.com) + +![Kittens!](kittens.jpg) diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/kittens.jpg b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/kittens.jpg new file mode 100644 index 00000000..a036ef99 Binary files /dev/null and b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/kittens.jpg differ diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/moby-dick.jpg b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/moby-dick.jpg new file mode 100644 index 00000000..3234c5c9 Binary files /dev/null and b/packages/core/dev-test/backends/proxy/_posts/2022-11-01-test/moby-dick.jpg differ diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test.md b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test.md deleted file mode 100644 index f9ff943f..00000000 --- a/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Test3 -draft: false -date: 2022-11-02 08:43 -image: /backends/proxy/assets/upload/kanefreeman_2.jpg ---- -test25555 diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/index.md b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/index.md new file mode 100644 index 00000000..9e6f8dc9 --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/index.md @@ -0,0 +1,7 @@ +--- +title: Test3 +draft: false +date: 2022-11-02 08:43 +image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg +--- +test2555556 diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/kanefreeman_2.jpg b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/kanefreeman_2.jpg new file mode 100644 index 00000000..6acd26df Binary files /dev/null and b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/kanefreeman_2.jpg differ diff --git a/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg new file mode 100644 index 00000000..fd99d1ea Binary files /dev/null and b/packages/core/dev-test/backends/proxy/_posts/2022-11-02-test/ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg differ diff --git a/packages/core/dev-test/backends/proxy/assets/upload/dalek.png b/packages/core/dev-test/backends/proxy/assets/upload/dalek.png deleted file mode 100644 index a2415581..00000000 Binary files a/packages/core/dev-test/backends/proxy/assets/upload/dalek.png and /dev/null differ diff --git a/packages/core/dev-test/backends/proxy/config.yml b/packages/core/dev-test/backends/proxy/config.yml index 1c8cade0..d4e926a7 100644 --- a/packages/core/dev-test/backends/proxy/config.yml +++ b/packages/core/dev-test/backends/proxy/config.yml @@ -24,14 +24,15 @@ collections: - name: posts label: Posts label_singular: Post - media_folder: /packages/core/dev-test/backends/proxy/assets/posts - public_folder: /backends/proxy/assets/posts description: > The description is a great place for tone setting, high level information, and editing guidelines that are specific to a collection. folder: packages/core/dev-test/backends/proxy/_posts - slug: '{{year}}-{{month}}-{{day}}-{{slug}}' summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + media_folder: "" + public_folder: "" + path: "{{year}}-{{month}}-{{day}}-{{slug}}/index" sortable_fields: fields: - title diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index 02944e60..ed498eb9 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -44,7 +44,7 @@ import { Cursor } from '../lib/util'; import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors'; import { - selectEntriesSortFields, + selectEntriesSortField, selectIsFetching, selectPublishedSlugs, } from '../reducers/selectors/entries'; @@ -653,10 +653,9 @@ export function loadEntries(collection: Collection, page = 0) { return; } const state = getState(); - const sortFields = selectEntriesSortFields(state, collection.name); - if (sortFields && sortFields.length > 0) { - const field = sortFields[0]; - return dispatch(sortByField(collection, field.key, field.direction)); + const sortField = selectEntriesSortField(collection.name)(state); + if (sortField) { + return dispatch(sortByField(collection, sortField.key, sortField.direction)); } const configState = state.config; @@ -956,7 +955,7 @@ export function persistEntry(collection: Collection) { const state = getState(); const entryDraft = state.entryDraft; const fieldsErrors = entryDraft.fieldsErrors; - const usedSlugs = selectPublishedSlugs(state, collection.name); + const usedSlugs = selectPublishedSlugs(collection.name)(state); // Early return if draft contains validation errors if (Object.keys(fieldsErrors).length > 0) { diff --git a/packages/core/src/actions/media.ts b/packages/core/src/actions/media.ts index 4fc9be0f..f330b21c 100644 --- a/packages/core/src/actions/media.ts +++ b/packages/core/src/actions/media.ts @@ -6,11 +6,9 @@ import { LOAD_ASSET_SUCCESS, REMOVE_ASSET, } from '../constants'; -import { isAbsolutePath } from '../lib/util'; import { selectMediaFilePath } from '../lib/util/media.util'; -import { selectMediaFileByPath } from '../reducers/selectors/mediaLibrary'; import { createAssetProxy } from '../valueObjects/AssetProxy'; -import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; +import { getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; @@ -58,18 +56,9 @@ async function loadAsset( dispatch(loadAssetRequest(resolvedPath)); // load asset url from backend await waitForMediaLibraryToLoad(dispatch, getState()); - const file = selectMediaFileByPath(getState(), resolvedPath); - - let asset: AssetProxy; - if (file) { - const url = await getMediaDisplayURL(dispatch, getState(), file); - asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath }); - dispatch(addAsset(asset)); - } else { - const { url } = await getMediaFile(getState(), resolvedPath); - asset = createAssetProxy({ path: resolvedPath, url }); - dispatch(addAsset(asset)); - } + const { url } = await getMediaFile(getState(), resolvedPath); + const asset = createAssetProxy({ path: resolvedPath, url }); + dispatch(addAsset(asset)); dispatch(loadAssetSuccess(resolvedPath)); return asset; } catch (error: unknown) { @@ -105,7 +94,8 @@ export function getAsset( path, field as Field, ); - let { asset, isLoading, error } = state.medias[resolvedPath] || {}; + + const { asset, isLoading } = state.medias[resolvedPath] || {}; if (isLoading) { return promiseCache[resolvedPath]; } @@ -116,23 +106,9 @@ export function getAsset( } const p = new Promise(resolve => { - if (isAbsolutePath(resolvedPath)) { - // asset path is a public url so we can just use it as is - asset = createAssetProxy({ path: resolvedPath, url: path }); - dispatch(addAsset(asset)); + loadAsset(resolvedPath, dispatch, getState).then(asset => { resolve(asset); - } else { - if (error) { - // on load error default back to original path - asset = createAssetProxy({ path: resolvedPath, url: path }); - dispatch(addAsset(asset)); - resolve(asset); - } else { - loadAsset(resolvedPath, dispatch, getState).then(asset => { - resolve(asset); - }); - } - } + }); }); promiseCache[resolvedPath] = p; diff --git a/packages/core/src/actions/mediaLibrary.ts b/packages/core/src/actions/mediaLibrary.ts index ff084792..4e04e9d4 100644 --- a/packages/core/src/actions/mediaLibrary.ts +++ b/packages/core/src/actions/mediaLibrary.ts @@ -34,6 +34,7 @@ import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { BaseField, + Collection, DisplayURLState, Field, ImplementationMediaFile, @@ -83,16 +84,28 @@ export function openMediaLibrary( allowMultiple?: boolean; replaceIndex?: number; config?: Record; + collection?: Collection; field?: F; } = {}, ) { return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const mediaLibrary = state.mediaLibrary.externalLibrary; - const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload; + const { + controlID, + value, + config = {}, + allowMultiple, + forImage, + replaceIndex, + collection, + field, + } = payload; + if (mediaLibrary) { mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage }); } + dispatch( mediaLibraryOpened({ controlID, @@ -101,6 +114,7 @@ export function openMediaLibrary( allowMultiple, replaceIndex, config, + collection: collection as Collection, field: field as Field, }), ); @@ -406,6 +420,7 @@ function mediaLibraryOpened(payload: { replaceIndex?: number; allowMultiple?: boolean; config?: Record; + collection?: Collection; field?: Field; }) { return { type: MEDIA_LIBRARY_OPEN, payload } as const; diff --git a/packages/core/src/components/App/App.tsx b/packages/core/src/components/App/App.tsx index 2b55c80f..f7ff714e 100644 --- a/packages/core/src/components/App/App.tsx +++ b/packages/core/src/components/App/App.tsx @@ -14,8 +14,8 @@ import { currentBackend } from '@staticcms/core/backend'; import { colors, GlobalStyles } from '@staticcms/core/components/UI/styles'; import { history } from '@staticcms/core/routing/history'; import { getDefaultPath } from '../../lib/util/collection.util'; -import CollectionRoute from '../Collection/CollectionRoute'; -import EditorRoute from '../Editor/EditorRoute'; +import CollectionRoute from '../collection/CollectionRoute'; +import EditorRoute from '../editor/EditorRoute'; import MediaLibrary from '../MediaLibrary/MediaLibrary'; import Page from '../page/Page'; import Snackbars from '../snackbar/Snackbars'; @@ -183,7 +183,7 @@ const App = ({ element={} /> } /> {'collection' in otherProps ? ( ) : ( , - t: t, -) { - return groups.map(group => { - const title = getGroupTitle(group, t); - return ( - - {title} - - - ); - }); -} - -interface EntriesToRenderProps { - entries: Entry[]; -} - -const EntriesCollection = ({ - collection, - entries, - groups, - isFetching, - viewStyle, - cursor, - page, - traverseCollectionCursor, - t, - entriesLoaded, - readyToLoad, - loadEntries, -}: TranslatedProps) => { - const [prevReadyToLoad, setPrevReadyToLoad] = useState(false); - const [prevCollection, setPrevCollection] = useState(collection); - - useEffect(() => { - if ( - collection && - !entriesLoaded && - readyToLoad && - (!prevReadyToLoad || prevCollection !== collection) - ) { - loadEntries(collection); - } - - setPrevReadyToLoad(readyToLoad); - setPrevCollection(collection); - }, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]); - - const handleCursorActions = useCallback( - (action: string) => { - traverseCollectionCursor(collection, action); - }, - [collection, traverseCollectionCursor], - ); - - const EntriesToRender = useCallback( - ({ entries }: EntriesToRenderProps) => { - return ( - - ); - }, - [collection, cursor, handleCursorActions, isFetching, page, viewStyle], - ); - - if (groups && groups.length > 0) { - return <>{withGroups(groups, entries, EntriesToRender, t)}; - } - - return ; -}; - export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) { const filtered = entries.filter(e => { const entryPath = e.path.slice(collectionFolder.length + 1); @@ -152,6 +63,94 @@ export function filterNestedEntries(path: string, collectionFolder: string, entr return filtered; } +const EntriesCollection = ({ + collection, + filterTerm, + isFetching, + viewStyle, + cursor, + page, + t, + entriesLoaded, + readyToLoad, +}: TranslatedProps) => { + const dispatch = useAppDispatch(); + + const [prevReadyToLoad, setPrevReadyToLoad] = useState(false); + const [prevCollection, setPrevCollection] = useState(collection); + + const groups = useGroups(collection.name); + + const entries = useEntries(collection); + + const filteredEntries = useMemo(() => { + if ('nested' in collection) { + const collectionFolder = collection.folder ?? ''; + return filterNestedEntries(filterTerm || '', collectionFolder, entries); + } + + return entries; + }, [collection, entries, filterTerm]); + + useEffect(() => { + if ( + collection && + !entriesLoaded && + readyToLoad && + (!prevReadyToLoad || prevCollection !== collection) + ) { + dispatch(loadEntries(collection)); + } + + setPrevReadyToLoad(readyToLoad); + setPrevCollection(collection); + }, [collection, dispatch, entriesLoaded, prevCollection, prevReadyToLoad, readyToLoad]); + + const handleCursorActions = useCallback( + (action: string) => { + dispatch(traverseCollectionCursor(collection, action)); + }, + [collection, dispatch], + ); + + if (groups && groups.length > 0) { + <> + {groups.map(group => { + const title = getGroupTitle(group, t); + return ( + + {title} + + + ); + })} + ; + } + + return ( + + ); +}; + interface EntriesCollectionOwnProps { collection: Collection; viewStyle: CollectionViewStyle; @@ -163,27 +162,16 @@ function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) const { collection, viewStyle, filterTerm } = ownProps; const page = state.entries.pages[collection.name]?.page; - let entries = selectEntries(state, collection); - const groups = selectGroups(state, collection); - - if ('nested' in collection) { - const collectionFolder = collection.folder ?? ''; - entries = filterNestedEntries(filterTerm || '', collectionFolder, entries); - } - const entriesLoaded = selectEntriesLoaded(state, collection.name); const isFetching = selectIsFetching(state, collection.name); const rawCursor = selectCollectionEntriesCursor(state, collection.name); const cursor = Cursor.create(rawCursor).clearData(); - return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor }; + return { ...ownProps, page, filterTerm, entriesLoaded, isFetching, viewStyle, cursor }; } -const mapDispatchToProps = { - loadEntries: loadEntriesAction, - traverseCollectionCursor: traverseCollectionCursorAction, -}; +const mapDispatchToProps = {}; const connector = connect(mapStateToProps, mapDispatchToProps); export type EntriesCollectionProps = ConnectedProps; diff --git a/packages/core/src/components/Collection/Entries/EntryCard.tsx b/packages/core/src/components/Collection/Entries/EntryCard.tsx index e102ed7a..9c5bc721 100644 --- a/packages/core/src/components/Collection/Entries/EntryCard.tsx +++ b/packages/core/src/components/Collection/Entries/EntryCard.tsx @@ -4,10 +4,8 @@ import CardContent from '@mui/material/CardContent'; import CardMedia from '@mui/material/CardMedia'; import Typography from '@mui/material/Typography'; import React, { useMemo } from 'react'; -import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -import { getAsset as getAssetAction } from '@staticcms/core/actions/media'; import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import { getPreviewCard } from '@staticcms/core/lib/registry'; @@ -17,28 +15,58 @@ import { selectTemplateName, } from '@staticcms/core/lib/util/collection.util'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; -import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias'; import { useAppSelector } from '@staticcms/core/store/hooks'; import useWidgetsFor from '../../common/widget/useWidgetsFor'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; -import type { Collection, Entry, Field } from '@staticcms/core/interface'; -import type { RootState } from '@staticcms/core/store'; -import type { ConnectedProps } from 'react-redux'; +import type { Collection, Entry, FileOrImageField, MediaField } from '@staticcms/core/interface'; + +export interface EntryCardProps { + entry: Entry; + imageFieldName?: string | null | undefined; + collection: Collection; + collectionLabel?: string; + viewStyle?: CollectionViewStyle; +} const EntryCard = ({ collection, entry, - path, - image, - imageField, collectionLabel, viewStyle = VIEW_STYLE_LIST, -}: NestedCollectionProps) => { + imageFieldName, +}: EntryCardProps) => { + const entryData = entry.data; + + const path = useMemo( + () => `/collections/${collection.name}/entries/${entry.slug}`, + [collection.name, entry.slug], + ); + + const imageField = useMemo( + () => + 'fields' in collection + ? (collection.fields?.find( + f => f.name === imageFieldName && f.widget === 'image', + ) as FileOrImageField) + : undefined, + [collection, imageFieldName], + ); + + const image = useMemo(() => { + let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined; + + if (i) { + i = encodeURI(i.trim()); + } + + return i; + }, [entryData, imageFieldName]); + const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const fields = selectFields(collection, entry.slug); - const imageUrl = useMediaAsset(image, collection, imageField, entry); + const imageUrl = useMediaAsset(image, collection as Collection, imageField, entry); const config = useAppSelector(selectConfig); @@ -97,51 +125,4 @@ const EntryCard = ({ ); }; -interface EntryCardOwnProps { - entry: Entry; - inferredFields: { - titleField?: string | null | undefined; - descriptionField?: string | null | undefined; - imageField?: string | null | undefined; - remainingFields?: Field[] | undefined; - }; - collection: Collection; - imageField?: Field; - collectionLabel?: string; - viewStyle?: CollectionViewStyle; -} - -function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) { - const { entry, inferredFields, collection } = ownProps; - const entryData = entry.data; - - let image = inferredFields.imageField - ? (entryData?.[inferredFields.imageField] as string | undefined) - : undefined; - - if (image) { - image = encodeURI(image.trim()); - } - - const isLoadingAsset = selectIsLoadingAsset(state); - - return { - ...ownProps, - path: `/collections/${collection.name}/entries/${entry.slug}`, - image, - imageField: - 'fields' in collection - ? collection.fields?.find(f => f.name === inferredFields.imageField && f.widget === 'image') - : undefined, - isLoadingAsset, - }; -} - -const mapDispatchToProps = { - getAsset: getAssetAction, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); -export type NestedCollectionProps = ConnectedProps; - -export default connector(EntryCard); +export default EntryCard; diff --git a/packages/core/src/components/Collection/Entries/EntryListing.tsx b/packages/core/src/components/Collection/Entries/EntryListing.tsx index 17a63c07..eed436d1 100644 --- a/packages/core/src/components/Collection/Entries/EntryListing.tsx +++ b/packages/core/src/components/Collection/Entries/EntryListing.tsx @@ -8,7 +8,7 @@ import { selectFields, selectInferredField } from '@staticcms/core/lib/util/coll import EntryCard from './EntryCard'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; -import type { Field, Collection, Collections, Entry } from '@staticcms/core/interface'; +import type { Collection, Collections, Entry, Field } from '@staticcms/core/interface'; import type Cursor from '@staticcms/core/lib/util/Cursor'; interface CardsGridProps { @@ -100,19 +100,19 @@ const EntryListing = ({ const renderedCards = useMemo(() => { if ('collection' in otherProps) { const inferredFields = inferFields(otherProps.collection); - return entries.map((entry, idx) => ( + return entries.map(entry => ( )); } const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1; - return entries.map((entry, idx) => { + return entries.map(entry => { const collectionName = entry.collection; const collection = Object.values(otherProps.collections).find( coll => coll.name === collectionName, @@ -123,9 +123,9 @@ const EntryListing = ({ ) : null; }); diff --git a/packages/core/src/components/Collection/NestedCollection.tsx b/packages/core/src/components/Collection/NestedCollection.tsx index 98b70438..47054e48 100644 --- a/packages/core/src/components/Collection/NestedCollection.tsx +++ b/packages/core/src/components/Collection/NestedCollection.tsx @@ -3,18 +3,15 @@ import { styled } from '@mui/material/styles'; import sortBy from 'lodash/sortBy'; import { dirname, sep } from 'path'; import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; import { NavLink } from 'react-router-dom'; import { colors, components } from '@staticcms/core/components/UI/styles'; import { transientOptions } from '@staticcms/core/lib'; +import useEntries from '@staticcms/core/lib/hooks/useEntries'; import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util'; import { stringTemplate } from '@staticcms/core/lib/widgets'; -import { selectEntries } from '@staticcms/core/reducers/selectors/entries'; import type { Collection, Entry } from '@staticcms/core/interface'; -import type { RootState } from '@staticcms/core/store'; -import type { ConnectedProps } from 'react-redux'; const { addFileTemplateFields } = stringTemplate; @@ -271,7 +268,14 @@ export function updateNode( return updater([...treeData]); } -const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => { +export interface NestedCollectionProps { + collection: Collection; + filterTerm: string; +} + +const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => { + const entries = useEntries(collection); + const [treeData, setTreeData] = useState(getTreeData(collection, entries)); const [selected, setSelected] = useState(null); const [useFilter, setUseFilter] = useState(true); @@ -337,18 +341,4 @@ const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionP return ; }; -interface NestedCollectionOwnProps { - collection: Collection; - filterTerm: string; -} - -function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) { - const { collection } = ownProps; - const entries = selectEntries(state, collection) ?? []; - return { ...ownProps, entries }; -} - -const connector = connect(mapStateToProps, {}); -export type NestedCollectionProps = ConnectedProps; - -export default connector(NestedCollection); +export default NestedCollection; diff --git a/packages/core/src/components/Editor/EditorRoute.tsx b/packages/core/src/components/Editor/EditorRoute.tsx index 9afccb40..ba2c3c77 100644 --- a/packages/core/src/components/Editor/EditorRoute.tsx +++ b/packages/core/src/components/Editor/EditorRoute.tsx @@ -12,7 +12,9 @@ interface EditorRouteProps { } const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => { - const { name, slug } = useParams(); + const { name, ...params } = useParams(); + const slug = params['*']; + const shouldRedirect = useMemo(() => { if (!name) { return false; diff --git a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx index d9be425c..5640ead6 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx @@ -1,6 +1,5 @@ import fuzzy from 'fuzzy'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; import { @@ -12,12 +11,13 @@ import { persistMedia as persistMediaAction, } from '@staticcms/core/actions/mediaLibrary'; import { fileExtension } from '@staticcms/core/lib/util'; +import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent'; import { selectMediaFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; import alert from '../UI/Alert'; import confirm from '../UI/Confirm'; import MediaLibraryModal from './MediaLibraryModal'; -import type { MediaFile, TranslatedProps } from '@staticcms/core/interface'; +import type { MediaFile } from '@staticcms/core/interface'; import type { RootState } from '@staticcms/core/store'; import type { ChangeEvent, KeyboardEvent } from 'react'; import type { ConnectedProps } from 'react-redux'; @@ -53,7 +53,7 @@ const MediaLibrary = ({ isDeleting, hasNextPage, isPaginating, - config, + config: mediaConfig, loadMedia, dynamicSearchQuery, page, @@ -61,31 +61,28 @@ const MediaLibrary = ({ deleteMedia, insertMedia, closeMediaLibrary, + collection, field, - t, -}: TranslatedProps) => { +}: MediaLibraryProps) => { const [selectedFile, setSelectedFile] = useState(null); const [query, setQuery] = useState(undefined); const [prevIsVisible, setPrevIsVisible] = useState(false); useEffect(() => { - loadMedia(); + loadMedia({}); }, [loadMedia]); useEffect(() => { if (!prevIsVisible && isVisible) { setSelectedFile(null); setQuery(''); + loadMedia(); + } else if (prevIsVisible && !isVisible) { + window.dispatchEvent(new MediaLibraryCloseEvent()); } setPrevIsVisible(isVisible); - }, [isVisible, prevIsVisible]); - - useEffect(() => { - if (!prevIsVisible && isVisible) { - loadMedia(); - } }, [isVisible, loadMedia, prevIsVisible]); const loadDisplayURL = useCallback( @@ -155,7 +152,7 @@ const MediaLibrary = ({ [selectedFile?.key], ); - const scrollContainerRef = useRef(); + const scrollContainerRef = useRef(null); const scrollToTop = () => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; @@ -189,7 +186,8 @@ const MediaLibrary = ({ event.preventDefault(); const files = [...Array.from(fileList)]; const file = files[0]; - const maxFileSize = typeof config.max_file_size === 'number' ? config.max_file_size : 512000; + const maxFileSize = + typeof mediaConfig.max_file_size === 'number' ? mediaConfig.max_file_size : 512000; if (maxFileSize && file.size > maxFileSize) { alert({ @@ -213,7 +211,7 @@ const MediaLibrary = ({ event.target.value = ''; } }, - [config.max_file_size, field, persistMedia], + [mediaConfig.max_file_size, field, persistMedia], ); /** @@ -310,7 +308,7 @@ const MediaLibrary = ({ /** * Filters files that do not match the query. Not used for dynamic search. */ - const queryFilter = useCallback((query: string, files: { name: string }[]) => { + const queryFilter = useCallback((query: string, files: MediaFile[]): MediaFile[] => { /** * Because file names don't have spaces, typing a space eliminates all * potential matches, so we strip them all out internally before running the @@ -318,11 +316,10 @@ const MediaLibrary = ({ */ const strippedQuery = query.replace(/ /g, ''); const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name }); - const matchFiles = matches.map((match, queryIndex) => { + return matches.map((match, queryIndex) => { const file = files[match.index]; return { ...file, queryIndex }; - }); - return matchFiles; + }) as MediaFile[]; }, []); return ( @@ -339,7 +336,7 @@ const MediaLibrary = ({ hasNextPage={hasNextPage} isPaginating={isPaginating} query={query} - selectedFile={selectedFile} + selectedFile={selectedFile ?? undefined} handleFilter={filterImages} handleQuery={queryFilter} toTableData={toTableData} @@ -350,12 +347,13 @@ const MediaLibrary = ({ handleDelete={handleDelete} handleInsert={handleInsert} handleDownload={handleDownload} - setScrollContainerRef={scrollContainerRef} + scrollContainerRef={scrollContainerRef} handleAssetClick={handleAssetClick} handleLoadMore={handleLoadMore} displayURLs={displayURLs} loadDisplayURL={loadDisplayURL} - t={t} + collection={collection} + field={field} /> ); }; @@ -379,6 +377,7 @@ function mapStateToProps(state: RootState) { page: mediaLibrary.page, hasNextPage: mediaLibrary.hasNextPage, isPaginating: mediaLibrary.isPaginating, + collection: mediaLibrary.collection, field, }; return { ...mediaLibraryProps }; @@ -396,4 +395,4 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); export type MediaLibraryProps = ConnectedProps; -export default connector(translate()(MediaLibrary)); +export default connector(MediaLibrary); diff --git a/packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx b/packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx index 7b0ae97d..b7cfa7a2 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibraryCard.tsx @@ -4,8 +4,11 @@ import React, { useEffect } from 'react'; import { borders, colors, effects, lengths, shadows } from '@staticcms/core/components/UI/styles'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import transientOptions from '@staticcms/core/lib/util/transientOptions'; +import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; +import { useAppSelector } from '@staticcms/core/store/hooks'; import type { MediaLibraryDisplayURL } from '@staticcms/core/reducers/mediaLibrary'; +import type { Field, Collection } from '@staticcms/core/interface'; const IMAGE_HEIGHT = 160; @@ -89,6 +92,8 @@ interface MediaLibraryCardProps { isViewableImage: boolean; loadDisplayURL: () => void; isDraft?: boolean; + collection?: Collection; + field?: Field; } const MediaLibraryCard = ({ @@ -103,9 +108,12 @@ const MediaLibraryCard = ({ type, isViewableImage, isDraft, + collection, + field, loadDisplayURL, }: MediaLibraryCardProps) => { - const url = useMediaAsset(displayURL.url); + const entry = useAppSelector(selectEditingDraft); + const url = useMediaAsset(displayURL.url, collection, field, entry); useEffect(() => { if (!displayURL.url) { diff --git a/packages/core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx b/packages/core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx index 99aa0b9d..0d25e380 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx @@ -6,12 +6,12 @@ import { FixedSizeGrid as Grid } from 'react-window'; import MediaLibraryCard from './MediaLibraryCard'; -import type { GridChildComponentProps } from 'react-window'; -import type { MediaFile } from '@staticcms/core/interface'; +import type { Collection, Field, MediaFile } from '@staticcms/core/interface'; import type { MediaLibraryDisplayURL, MediaLibraryState, } from '@staticcms/core/reducers/mediaLibrary'; +import type { GridChildComponentProps } from 'react-window'; export interface MediaLibraryCardItem { displayURL?: MediaLibraryDisplayURL; @@ -25,7 +25,7 @@ export interface MediaLibraryCardItem { } export interface MediaLibraryCardGridProps { - setScrollContainerRef: () => void; + scrollContainerRef: React.MutableRefObject; mediaItems: MediaFile[]; isSelectedFile: (file: MediaFile) => boolean; onAssetClick: (asset: MediaFile) => void; @@ -39,6 +39,8 @@ export interface MediaLibraryCardGridProps { cardMargin: string; loadDisplayURL: (asset: MediaFile) => void; displayURLs: MediaLibraryState['displayURLs']; + collection?: Collection; + field?: Field; } export type CardGridItemData = MediaLibraryCardGridProps & { @@ -61,6 +63,8 @@ const CardWrapper = ({ loadDisplayURL, columnCount, gutter, + collection, + field, }, }: GridChildComponentProps) => { const index = rowIndex * columnCount + columnIndex; @@ -93,6 +97,8 @@ const CardWrapper = ({ loadDisplayURL={() => loadDisplayURL(file)} type={file.type} isViewableImage={file.isViewableImage ?? false} + collection={collection} + field={field} /> ); @@ -126,7 +132,7 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => { cardHeight: inputCardHeight, cardMargin, mediaItems, - setScrollContainerRef, + scrollContainerRef, } = props; return ( @@ -141,7 +147,7 @@ const VirtualizedGrid = (props: MediaLibraryCardGridProps) => { const rowCount = Math.ceil(mediaItems.length / columnCount); return ( - + { }; const PaginatedGrid = ({ - setScrollContainerRef, + scrollContainerRef, mediaItems, isSelectedFile, onAssetClick, @@ -182,9 +188,11 @@ const PaginatedGrid = ({ onLoadMore, isPaginating, paginatingMessage, + collection, + field, }: MediaLibraryCardGridProps) => { return ( - + {mediaItems.map(file => ( loadDisplayURL(file)} type={file.type} isViewableImage={file.isViewableImage ?? false} + collection={collection} + field={field} /> ))} {!canLoadMore ? null : } diff --git a/packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx b/packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx index 12f9645b..b8fe049a 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibraryModal.tsx @@ -11,9 +11,9 @@ import EmptyMessage from './EmptyMessage'; import MediaLibraryCardGrid from './MediaLibraryCardGrid'; import MediaLibraryTop from './MediaLibraryTop'; -import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react'; -import type { MediaFile, TranslatedProps } from '@staticcms/core/interface'; +import type { Collection, Field, MediaFile, TranslatedProps } from '@staticcms/core/interface'; import type { MediaLibraryState } from '@staticcms/core/reducers/mediaLibrary'; +import type { ChangeEvent, ChangeEventHandler, FC, KeyboardEventHandler } from 'react'; const StyledFab = styled(Fab)` position: absolute; @@ -95,11 +95,13 @@ interface MediaLibraryModalProps { handleDelete: () => void; handleInsert: () => void; handleDownload: () => void; - setScrollContainerRef: () => void; + scrollContainerRef: React.MutableRefObject; handleAssetClick: (asset: MediaFile) => void; handleLoadMore: () => void; loadDisplayURL: (file: MediaFile) => void; displayURLs: MediaLibraryState['displayURLs']; + collection?: Collection; + field?: Field; } const MediaLibraryModal = ({ @@ -126,11 +128,13 @@ const MediaLibraryModal = ({ handleDelete, handleInsert, handleDownload, - setScrollContainerRef, + scrollContainerRef, handleAssetClick, handleLoadMore, loadDisplayURL, displayURLs, + collection, + field, t, }: TranslatedProps) => { const filteredFiles = forImage ? handleFilter(files) : files; @@ -177,7 +181,7 @@ const MediaLibraryModal = ({ {!shouldShowEmptyMessage ? null : } selectedFile?.key === file.key} onAssetClick={handleAssetClick} @@ -191,10 +195,12 @@ const MediaLibraryModal = ({ cardMargin={cardMargin} loadDisplayURL={loadDisplayURL} displayURLs={displayURLs} + collection={collection} + field={field} /> ); }; -export default translate()(MediaLibraryModal); +export default translate()(MediaLibraryModal) as FC; diff --git a/packages/core/src/components/common/image/Image.tsx b/packages/core/src/components/common/image/Image.tsx new file mode 100644 index 00000000..701b9bf7 --- /dev/null +++ b/packages/core/src/components/common/image/Image.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; +import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; +import { useAppSelector } from '@staticcms/core/store/hooks'; + +import type { Collection, MediaField } from '@staticcms/core/interface'; + +export interface ImageProps { + src?: string; + alt?: string; + collection?: Collection; + field?: F; +} + +const Image = ({ src, alt, collection, field }: ImageProps) => { + const entry = useAppSelector(selectEditingDraft); + + const assetSource = useMediaAsset(src, collection, field, entry); + + return {alt}; +}; + +export const withMdxImage = ({ + collection, + field, +}: Omit, 'src' | 'alt'>) => { + const MdxImage = ({ src, alt }: Pick, 'src' | 'alt'>) => ( + {alt} + ); + + return MdxImage; +}; + +export default Image; diff --git a/packages/core/src/components/page/Page.tsx b/packages/core/src/components/page/Page.tsx index 4a83528b..4bda71db 100644 --- a/packages/core/src/components/page/Page.tsx +++ b/packages/core/src/components/page/Page.tsx @@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom'; import { getAdditionalLink } from '@staticcms/core/lib/registry'; import MainView from '../App/MainView'; -import Sidebar from '../Collection/Sidebar'; +import Sidebar from '../collection/Sidebar'; import type { ComponentType } from 'react'; import type { ConnectedProps } from 'react-redux'; diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 804824a9..e8726bac 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -535,6 +535,12 @@ export interface BaseField { widget: string; } +export interface MediaField extends BaseField { + media_library?: MediaLibrary; + media_folder?: string; + public_folder?: string; +} + export interface BooleanField extends BaseField { widget: 'boolean'; default?: boolean; @@ -572,13 +578,9 @@ export interface DateTimeField extends BaseField { picker_utc?: boolean; } -export interface FileOrImageField extends BaseField { +export interface FileOrImageField extends MediaField { widget: 'file' | 'image'; default?: string; - - media_library?: MediaLibrary; - media_folder?: string; - public_folder?: string; } export interface ObjectField extends BaseField { @@ -614,13 +616,9 @@ export interface MapField extends BaseField { height?: string; } -export interface MarkdownField extends BaseField { +export interface MarkdownField extends MediaField { widget: 'markdown'; default?: string; - - media_library?: MediaLibrary; - media_folder?: string; - public_folder?: string; } export interface NumberField extends BaseField { diff --git a/packages/core/src/lib/hooks/useEntries.ts b/packages/core/src/lib/hooks/useEntries.ts new file mode 100644 index 00000000..94dfc9db --- /dev/null +++ b/packages/core/src/lib/hooks/useEntries.ts @@ -0,0 +1,51 @@ +import get from 'lodash/get'; +import orderBy from 'lodash/orderBy'; +import { useMemo } from 'react'; + +import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants'; +import { selectEntriesSortField } from '@staticcms/core/reducers/selectors/entries'; +import { useAppSelector } from '@staticcms/core/store/hooks'; +import { selectSortDataPath } from '../util/sort.util'; +import useFilters from './useFilters'; +import usePublishedEntries from './usePublishedEntries'; + +import type { Collection } from '@staticcms/core/interface'; + +export default function useEntries(collection: Collection) { + const entries = usePublishedEntries(collection.name); + + const entriesSortFieldSelector = useMemo( + () => selectEntriesSortField(collection.name), + [collection.name], + ); + const sortField = useAppSelector(entriesSortFieldSelector); + + const filters = useFilters(collection.name); + + return useMemo(() => { + let finalEntries = [...entries]; + + if (sortField) { + const key = selectSortDataPath(collection, sortField.key); + const order = sortField.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc'; + finalEntries = orderBy(finalEntries, key, order); + } + + if (filters && filters.length > 0) { + finalEntries = finalEntries.filter(e => { + const allMatched = filters.every(f => { + const pattern = f.pattern; + const field = f.field; + const data = e!.data || {}; + const toMatch = get(data, field); + const matched = + toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); + return matched; + }); + return allMatched; + }); + } + + return finalEntries; + }, [collection, entries, filters, sortField]); +} diff --git a/packages/core/src/lib/hooks/useFilters.ts b/packages/core/src/lib/hooks/useFilters.ts new file mode 100644 index 00000000..d1a177a9 --- /dev/null +++ b/packages/core/src/lib/hooks/useFilters.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; + +import { selectEntriesSortField } from '@staticcms/core/reducers/selectors/entries'; +import { useAppSelector } from '@staticcms/core/store/hooks'; + +export default function useFilters(collectionName: string) { + const entriesSortFieldSelector = useMemo( + () => selectEntriesSortField(collectionName), + [collectionName], + ); + const filters = useAppSelector(entriesSortFieldSelector); + + return useMemo(() => { + return Object.values(filters ?? {}).filter(v => v?.active === true) || []; + }, [filters]); +} diff --git a/packages/core/src/lib/hooks/useGroups.ts b/packages/core/src/lib/hooks/useGroups.ts new file mode 100644 index 00000000..8f44578d --- /dev/null +++ b/packages/core/src/lib/hooks/useGroups.ts @@ -0,0 +1,41 @@ +import groupBy from 'lodash/groupBy'; +import { useMemo } from 'react'; + +import { getGroup, selectEntriesGroupField } from '@staticcms/core/reducers/selectors/entries'; +import { useAppSelector } from '@staticcms/core/store/hooks'; +import usePublishedEntries from './usePublishedEntries'; + +import type { GroupOfEntries } from '@staticcms/core/interface'; + +export default function useGroups(collectionName: string) { + const entries = usePublishedEntries(collectionName); + + const entriesGroupFieldSelector = useMemo( + () => selectEntriesGroupField(collectionName), + [collectionName], + ); + const selectedGroup = useAppSelector(entriesGroupFieldSelector); + + return useMemo(() => { + if (selectedGroup === undefined) { + return []; + } + + let groups: Record = + {}; + const groupedEntries = groupBy(entries, entry => { + const group = getGroup(entry, selectedGroup); + groups = { ...groups, [group.id]: group }; + return group.id; + }); + + const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => { + return { + ...groups[id], + paths: new Set(entries.map(entry => entry.path)), + }; + }); + + return groupsArray; + }, [entries, selectedGroup]); +} diff --git a/packages/core/src/lib/hooks/useIsMediaAsset.ts b/packages/core/src/lib/hooks/useIsMediaAsset.ts index 541675dd..b4d0b59a 100644 --- a/packages/core/src/lib/hooks/useIsMediaAsset.ts +++ b/packages/core/src/lib/hooks/useIsMediaAsset.ts @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'; import { emptyAsset, getAsset } from '@staticcms/core/actions/media'; import { useAppDispatch } from '@staticcms/core/store/hooks'; -import { isNotEmpty } from '../util/string.util'; +import { isEmpty, isNotEmpty } from '../util/string.util'; +import useDebounce from './useDebounce'; -import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface'; +import type { Collection, Entry, MediaField } from '@staticcms/core/interface'; -export default function useIsMediaAsset( +export default function useIsMediaAsset( url: string, collection: Collection, field: T, @@ -14,17 +15,22 @@ export default function useIsMediaAsset { + if (isEmpty(debouncedUrl)) { + return; + } + const checkMediaExistence = async () => { - const asset = await dispatch(getAsset(collection, entry, url, field)); + const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); setExists( Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj), ); }; checkMediaExistence(); - }, [collection, dispatch, entry, field, url]); + }, [collection, dispatch, entry, field, debouncedUrl]); return exists; } diff --git a/packages/core/src/lib/hooks/useMediaAsset.ts b/packages/core/src/lib/hooks/useMediaAsset.ts index ff96f63b..be6211f6 100644 --- a/packages/core/src/lib/hooks/useMediaAsset.ts +++ b/packages/core/src/lib/hooks/useMediaAsset.ts @@ -1,34 +1,39 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { emptyAsset, getAsset } from '@staticcms/core/actions/media'; import { useAppDispatch } from '@staticcms/core/store/hooks'; -import { isNotEmpty } from '../util/string.util'; +import useDebounce from './useDebounce'; -import type { Field, Collection, Entry } from '@staticcms/core/interface'; +import type { Collection, Entry, MediaField } from '@staticcms/core/interface'; -export default function useMediaAsset( +export default function useMediaAsset( url: string | undefined, collection?: Collection, field?: T, entry?: Entry, ): string { const dispatch = useAppDispatch(); - const [assetSource, setAssetSource] = useState(url); + const [assetSource, setAssetSource] = useState(''); + const debouncedUrl = useDebounce(url, 200); useEffect(() => { - if (!url) { + if (!debouncedUrl) { return; } const fetchMedia = async () => { - const asset = await dispatch(getAsset(collection, entry, url, field)); + const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); if (asset !== emptyAsset) { setAssetSource(asset?.toString() ?? ''); } }; fetchMedia(); - }, [collection, dispatch, entry, field, url]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedUrl]); - return isNotEmpty(assetSource) ? assetSource : url ?? ''; + return useMemo( + () => (debouncedUrl?.startsWith('blob:') ? debouncedUrl : assetSource ?? ''), + [assetSource, debouncedUrl], + ); } diff --git a/packages/core/src/lib/hooks/useMediaInsert.ts b/packages/core/src/lib/hooks/useMediaInsert.ts index 39c9a7f2..6e44432e 100644 --- a/packages/core/src/lib/hooks/useMediaInsert.ts +++ b/packages/core/src/lib/hooks/useMediaInsert.ts @@ -5,17 +5,17 @@ import { openMediaLibrary, removeInsertedMedia } from '@staticcms/core/actions/m import { selectMediaPath } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; -import type { FileOrImageField, MarkdownField } from '@staticcms/core/interface'; +import type { Collection, MediaField } from '@staticcms/core/interface'; import type { MouseEvent } from 'react'; -export default function useMediaInsert( +export default function useMediaInsert( value: T, - options: { field?: FileOrImageField | MarkdownField; controlID?: string; forImage?: boolean }, + options: { collection: Collection; field: F; controlID?: string; forImage?: boolean }, callback: (newValue: T) => void, ): (e?: MouseEvent) => void { const dispatch = useAppDispatch(); - const { controlID, field, forImage = false } = options; + const { controlID, collection, field, forImage = false } = options; const finalControlID = useMemo(() => controlID ?? uuid(), [controlID]); const mediaPathSelector = useMemo(() => selectMediaPath(finalControlID), [finalControlID]); @@ -50,11 +50,12 @@ export default function useMediaInsert( replaceIndex, allowMultiple: false, config, + collection, field, }), ); }, - [dispatch, finalControlID, forImage, value, config, field], + [dispatch, finalControlID, forImage, value, config, collection, field], ); return handleOpenMediaLibrary; diff --git a/packages/core/src/lib/hooks/usePublishedEntries.ts b/packages/core/src/lib/hooks/usePublishedEntries.ts new file mode 100644 index 00000000..73e9300b --- /dev/null +++ b/packages/core/src/lib/hooks/usePublishedEntries.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { + selectEntriesBySlugs, + selectPublishedSlugs, +} from '@staticcms/core/reducers/selectors/entries'; +import { useAppSelector } from '@staticcms/core/store/hooks'; + +export default function usePublishedEntries(collectionName: string) { + const publishedSlugsSelector = useMemo( + () => selectPublishedSlugs(collectionName), + [collectionName], + ); + + const slugs = useAppSelector(publishedSlugsSelector); + const entries = useAppSelector(selectEntriesBySlugs); + + return useMemo( + () => slugs && slugs.map(slug => entries[`${collectionName}.${slug}`]), + [collectionName, entries, slugs], + ); +} diff --git a/packages/core/src/lib/util/collection.util.ts b/packages/core/src/lib/util/collection.util.ts index 36004ab4..9e6136ee 100644 --- a/packages/core/src/lib/util/collection.util.ts +++ b/packages/core/src/lib/util/collection.util.ts @@ -258,6 +258,7 @@ export function selectMediaFolders(config: Config, collection: Collection, entry return [...new Set(folders)]; } + export function getFieldsNames(fields: Field[] | undefined, prefix = '') { let names = fields?.map(f => `${prefix}${f.name}`) ?? []; diff --git a/packages/core/src/lib/util/events/MediaLibraryCloseEvent.ts b/packages/core/src/lib/util/events/MediaLibraryCloseEvent.ts new file mode 100644 index 00000000..2fedd718 --- /dev/null +++ b/packages/core/src/lib/util/events/MediaLibraryCloseEvent.ts @@ -0,0 +1,5 @@ +export default class MediaLibraryCloseEvent extends CustomEvent<{}> { + constructor() { + super('mediaLibraryClose', {}); + } +} diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index ad52ca12..77dc4b90 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -49,12 +49,12 @@ function hasCustomFolder( if ('files' in collection) { const file = getFileField(collection.files, slug); - if (file && file[folderKey]) { + if (file && folderKey in file) { return true; } } - if (collection[folderKey]) { + if (folderKey in collection) { return true; } @@ -72,7 +72,7 @@ function evaluateFolder( const collection = { ...c }; // add identity template if doesn't exist - if (!collection[folderKey]) { + if (!(folderKey in collection)) { collection[folderKey] = `{{${folderKey}}}`; } diff --git a/packages/core/src/lib/util/window.util.ts b/packages/core/src/lib/util/window.util.ts index 0adee83c..c66b4964 100644 --- a/packages/core/src/lib/util/window.util.ts +++ b/packages/core/src/lib/util/window.util.ts @@ -2,10 +2,12 @@ import { useEffect } from 'react'; import type AlertEvent from './events/AlertEvent'; import type ConfirmEvent from './events/ConfirmEvent'; +import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent'; interface EventMap { alert: AlertEvent; confirm: ConfirmEvent; + mediaLibraryClose: MediaLibraryCloseEvent; } export function useWindowEvent( diff --git a/packages/core/src/reducers/mediaLibrary.ts b/packages/core/src/reducers/mediaLibrary.ts index 7a6890da..13e9fd0f 100644 --- a/packages/core/src/reducers/mediaLibrary.ts +++ b/packages/core/src/reducers/mediaLibrary.ts @@ -21,7 +21,7 @@ import { } from '../constants'; import type { MediaLibraryAction } from '../actions/mediaLibrary'; -import type { Field, MediaFile, MediaLibraryInstance } from '../interface'; +import type { Collection, Field, MediaFile, MediaLibraryInstance } from '../interface'; export interface MediaLibraryDisplayURL { url?: string; @@ -39,6 +39,7 @@ export type MediaLibraryState = { page?: number; files?: MediaFile[]; config: Record; + collection?: Collection; field?: Field; value?: string | string[]; replaceIndex?: number; @@ -75,7 +76,8 @@ function mediaLibrary( }; case MEDIA_LIBRARY_OPEN: { - const { controlID, forImage, config, field, value, replaceIndex } = action.payload; + const { controlID, forImage, config, collection, field, value, replaceIndex } = + action.payload; const libConfig = config || {}; return { @@ -85,6 +87,7 @@ function mediaLibrary( controlID, canInsert: !!controlID, config: libConfig, + collection, field, value, replaceIndex, diff --git a/packages/core/src/reducers/selectors/entries.ts b/packages/core/src/reducers/selectors/entries.ts index e48723d6..cba74d34 100644 --- a/packages/core/src/reducers/selectors/entries.ts +++ b/packages/core/src/reducers/selectors/entries.ts @@ -1,20 +1,9 @@ import get from 'lodash/get'; -import groupBy from 'lodash/groupBy'; -import orderBy from 'lodash/orderBy'; -import { SORT_DIRECTION_ASCENDING, SORT_DIRECTION_NONE } from '@staticcms/core/constants'; -import { selectSortDataPath } from '@staticcms/core/lib/util/sort.util'; +import { SORT_DIRECTION_NONE } from '@staticcms/core/constants'; import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews'; -import type { - Collection, - Entry, - Filter, - Group, - GroupMap, - GroupOfEntries, - Sort, -} from '@staticcms/core/interface'; +import type { Entry, Group, GroupMap, Sort } from '@staticcms/core/interface'; import type { RootState } from '@staticcms/core/store'; export function selectEntriesSort(entries: RootState, collection: string) { @@ -22,84 +11,49 @@ export function selectEntriesSort(entries: RootState, collection: string) { return sort?.[collection]; } -export function selectEntriesFilter(entries: RootState, collection: string) { - const filter = entries.entries.filter as Filter | undefined; - return filter?.[collection] || {}; -} +export const selectEntriesFilter = (collectionName: string) => (entries: RootState) => { + return entries.entries.filter?.[collectionName]; +}; export function selectEntriesGroup(entries: RootState, collection: string) { const group = entries.entries.group as Group | undefined; return group?.[collection] || {}; } -export function selectEntriesGroupField(entries: RootState, collection: string) { +export const selectEntriesGroupField = (collection: string) => (entries: RootState) => { const groups = selectEntriesGroup(entries, collection); - const value = Object.values(groups ?? {}).find(v => v?.active === true); - return value; -} + return Object.values(groups ?? {}).find(v => v?.active === true); +}; -export function selectEntriesSortFields(entries: RootState, collection: string) { - const sort = selectEntriesSort(entries, collection); - const values = Object.values(sort ?? {}).filter(v => v?.direction !== SORT_DIRECTION_NONE) || []; - - return values; -} - -export function selectEntriesFilterFields(entries: RootState, collection: string) { - const filter = selectEntriesFilter(entries, collection); - const values = Object.values(filter ?? {}).filter(v => v?.active === true) || []; - return values; -} +export const selectEntriesSortField = (collectionName: string) => (entries: RootState) => { + const sort = selectEntriesSort(entries, collectionName); + return Object.values(sort ?? {}).find(v => v?.direction !== SORT_DIRECTION_NONE); +}; export function selectViewStyle(entries: RootState): CollectionViewStyle { return entries.entries.viewStyle; } +export function selectEntriesBySlugs(state: RootState) { + return state.entries.entities; +} + export function selectEntry(state: RootState, collection: string, slug: string) { return state.entries.entities[`${collection}.${slug}`]; } -export function selectPublishedSlugs(state: RootState, collection: string) { +export const selectPublishedSlugs = (collection: string) => (state: RootState) => { return state.entries.pages[collection]?.ids ?? []; -} +}; -function getPublishedEntries(state: RootState, collectionName: string) { - const slugs = selectPublishedSlugs(state, collectionName); - const entries = - slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]); - return entries; -} +export const selectPublishedEntries = (collectionName: string) => (state: RootState) => { + const slugs = selectPublishedSlugs(collectionName)(state); + return ( + slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]) + ); +}; -export function selectEntries(state: RootState, collection: Collection) { - const collectionName = collection.name; - let entries = getPublishedEntries(state, collectionName); - - const sortFields = selectEntriesSortFields(state, collectionName); - if (sortFields && sortFields.length > 0) { - const keys = sortFields.map(v => selectSortDataPath(collection, v.key)); - const orders = sortFields.map(v => (v.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc')); - entries = orderBy(entries, keys, orders); - } - - const filters = selectEntriesFilterFields(state, collectionName); - if (filters && filters.length > 0) { - entries = entries.filter(e => { - const allMatched = filters.every(f => { - const pattern = f.pattern; - const field = f.field; - const data = e!.data || {}; - const toMatch = get(data, field); - const matched = toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); - return matched; - }); - return allMatched; - }); - } - - return entries; -} - -function getGroup(entry: Entry, selectedGroup: GroupMap) { +export function getGroup(entry: Entry, selectedGroup: GroupMap) { const label = selectedGroup.label; const field = selectedGroup.field; @@ -139,35 +93,8 @@ function getGroup(entry: Entry, selectedGroup: GroupMap) { }; } -export function selectGroups(state: RootState, collection: Collection) { - const collectionName = collection.name; - const entries = getPublishedEntries(state, collectionName); - - const selectedGroup = selectEntriesGroupField(state, collectionName); - if (selectedGroup === undefined) { - return []; - } - - let groups: Record = - {}; - const groupedEntries = groupBy(entries, entry => { - const group = getGroup(entry, selectedGroup); - groups = { ...groups, [group.id]: group }; - return group.id; - }); - - const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => { - return { - ...groups[id], - paths: new Set(entries.map(entry => entry.path)), - }; - }); - - return groupsArray; -} - export function selectEntryByPath(state: RootState, collection: string, path: string) { - const slugs = selectPublishedSlugs(state, collection); + const slugs = selectPublishedSlugs(collection)(state); const entries = slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]); diff --git a/packages/core/src/widgets/file/withFileControl.tsx b/packages/core/src/widgets/file/withFileControl.tsx index 77c7863a..2a596e9a 100644 --- a/packages/core/src/widgets/file/withFileControl.tsx +++ b/packages/core/src/widgets/file/withFileControl.tsx @@ -238,7 +238,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { const handleOpenMediaLibrary = useMediaInsert( internalValue, - { field, controlID }, + { collection, field, controlID, forImage }, handleOnChange, ); @@ -312,10 +312,11 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { replaceIndex: index, allowMultiple: false, config, + collection, field, }); }, - [config, controlID, field, openMediaLibrary, internalValue], + [openMediaLibrary, controlID, internalValue, config, collection, field], ); // TODO Readd when multiple uploads is supported diff --git a/packages/core/src/widgets/list/ListItem.tsx b/packages/core/src/widgets/list/ListItem.tsx index d0d670f6..76f74efc 100644 --- a/packages/core/src/widgets/list/ListItem.tsx +++ b/packages/core/src/widgets/list/ListItem.tsx @@ -2,7 +2,7 @@ import { styled } from '@mui/material/styles'; import partial from 'lodash/partial'; import React, { useCallback, useMemo, useState } from 'react'; -import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/EditorControl'; +import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl'; import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar'; import Outline from '@staticcms/core/components/UI/Outline'; import { colors } from '@staticcms/core/components/UI/styles'; diff --git a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx index 0541d2e4..9dc39f3b 100644 --- a/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx +++ b/packages/core/src/widgets/list/__tests__/ListControl.spec.tsx @@ -52,7 +52,7 @@ const ListControlWrapper = createControlWrapper({ path: 'list', }); -jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { +jest.mock('@staticcms/core/components/editor/EditorControlPane/EditorControl', () => { return jest.fn(props => { const { parentPath, field, value } = props; return ( diff --git a/packages/core/src/widgets/markdown/MarkdownPreview.tsx b/packages/core/src/widgets/markdown/MarkdownPreview.tsx index 08de9525..c0385f03 100644 --- a/packages/core/src/widgets/markdown/MarkdownPreview.tsx +++ b/packages/core/src/widgets/markdown/MarkdownPreview.tsx @@ -7,6 +7,7 @@ import { getShortcodes } from '../../lib/registry'; import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent'; import useMdx from './plate/hooks/useMdx'; import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig'; +import { withMdxImage } from '@staticcms/core/components/common/image/Image'; import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; import type { FC } from 'react'; @@ -26,13 +27,14 @@ function FallbackComponent({ error }: FallbackComponentProps) { } const MarkdownPreview: FC> = previewProps => { - const { value } = previewProps; + const { value, collection, field } = previewProps; const components = useMemo( () => ({ Shortcode: withShortcodeMdxComponent({ previewProps }), + img: withMdxImage({ collection, field }), }), - [previewProps], + [collection, field, previewProps], ); const [state, setValue] = useMdx(value ?? ''); diff --git a/packages/core/src/widgets/markdown/plate/components/common/MediaPopover.tsx b/packages/core/src/widgets/markdown/plate/components/common/MediaPopover.tsx index 49ba764e..7748cb86 100644 --- a/packages/core/src/widgets/markdown/plate/components/common/MediaPopover.tsx +++ b/packages/core/src/widgets/markdown/plate/components/common/MediaPopover.tsx @@ -5,11 +5,10 @@ import Popper from '@mui/material/Popper'; import { styled, useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useFocused } from 'slate-react'; -import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import useIsMediaAsset from '@staticcms/core/lib/hooks/useIsMediaAsset'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; +import { useWindowEvent } from '@staticcms/core/lib/util/window.util'; import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface'; import type { ChangeEvent, KeyboardEvent } from 'react'; @@ -65,7 +64,6 @@ export interface MediaPopoverProps { onUrlChange: (newValue: string) => void; onTextChange?: (newValue: string) => void; onClose: (shouldFocus: boolean) => void; - mediaOpen?: boolean; onMediaToggle?: (open: boolean) => void; onMediaChange: (newValue: string) => void; onRemove?: () => void; @@ -87,7 +85,6 @@ const MediaPopover = ({ onUrlChange, onTextChange, onClose, - mediaOpen, onMediaToggle, onMediaChange, onRemove, @@ -101,9 +98,9 @@ const MediaPopover = ({ const [editing, setEditing] = useState(inserting); - const hasEditorFocus = useFocused(); - const [hasFocus, setHasFocus] = useState(false); - const debouncedHasFocus = useDebounce(hasFocus, 150); + useWindowEvent('mediaLibraryClose', () => { + onMediaToggle?.(false); + }); const handleClose = useCallback( (shouldFocus: boolean) => { @@ -155,60 +152,11 @@ const MediaPopover = ({ } }, [anchorEl, editing, inserting, urlDisabled]); - const [ - { prevAnchorEl, prevHasEditorFocus, prevHasFocus, prevDebouncedHasFocus }, - setPrevFocusState, - ] = useState<{ - prevAnchorEl: HTMLElement | null; - prevHasEditorFocus: boolean; - prevHasFocus: boolean; - prevDebouncedHasFocus: boolean; - }>({ - prevAnchorEl: anchorEl, - prevHasEditorFocus: hasEditorFocus, - prevHasFocus: hasFocus, - prevDebouncedHasFocus: debouncedHasFocus, - }); - - useEffect(() => { - if (mediaOpen) { - return; - } - - if (anchorEl && !prevHasEditorFocus && hasEditorFocus) { - handleClose(false); - } - - if (anchorEl && (prevHasFocus || prevDebouncedHasFocus) && !hasFocus && !debouncedHasFocus) { - handleClose(false); - } - - setPrevFocusState({ - prevAnchorEl: anchorEl, - prevHasEditorFocus: hasEditorFocus, - prevHasFocus: hasFocus, - prevDebouncedHasFocus: debouncedHasFocus, - }); - }, [ - anchorEl, - debouncedHasFocus, - handleClose, - hasEditorFocus, - hasFocus, - mediaOpen, - prevAnchorEl, - prevDebouncedHasFocus, - prevHasEditorFocus, - prevHasFocus, - ]); - const handleFocus = useCallback(() => { - setHasFocus(true); onFocus?.(); }, [onFocus]); const handleBlur = useCallback(() => { - setHasFocus(false); onBlur?.(); }, [onBlur]); @@ -220,7 +168,11 @@ const MediaPopover = ({ [onMediaChange, onMediaToggle], ); - const handleOpenMediaLibrary = useMediaInsert(url, { field, forImage }, handleMediaChange); + const handleOpenMediaLibrary = useMediaInsert( + url, + { collection, field, forImage }, + handleMediaChange, + ); const handleMediaOpen = useCallback(() => { onMediaToggle?.(true); diff --git a/packages/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx b/packages/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx index 77e9b02d..e7af3da8 100644 --- a/packages/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx +++ b/packages/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx @@ -12,6 +12,7 @@ import { useFocused } from 'slate-react'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import { isEmpty } from '@staticcms/core/lib/util/string.util'; import { MediaPopover } from '@staticcms/markdown'; +import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { MdImageElement, MdValue } from '@staticcms/markdown'; @@ -35,13 +36,32 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE const { url, alt } = element; const [internalUrl, setInternalUrl] = useState(url); const [internalAlt, setInternalAlt] = useState(alt); + const [popoverHasFocus, setPopoverHasFocus] = useState(false); + const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100); + + const [mediaOpen, setMediaOpen] = useState(false); const imageRef = useRef(null); const [anchorEl, setAnchorEl] = useState(null); const hasEditorFocus = useFocused(); + const debouncedHasEditorFocus = useDebounce(hasEditorFocus, 100); const handleBlur = useCallback(() => { - setAnchorEl(null); + if (!popoverHasFocus && !mediaOpen) { + setAnchorEl(null); + } + }, [mediaOpen, popoverHasFocus]); + + const handlePopoverFocus = useCallback(() => { + setPopoverHasFocus(true); + }, []); + + const handlePopoverBlur = useCallback(() => { + setPopoverHasFocus(false); + }, []); + + const handleMediaToggle = useCallback(() => { + setMediaOpen(oldMediaOpen => !oldMediaOpen); }, []); const handleChange = useCallback( @@ -96,7 +116,28 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE const selection = usePlateSelection(); useEffect(() => { - if (!hasEditorFocus || !selection) { + if ( + hasEditorFocus || + debouncedHasEditorFocus || + mediaOpen || + popoverHasFocus || + debouncedPopoverHasFocus + ) { + return; + } + + handleClose(); + }, [ + debouncedHasEditorFocus, + debouncedPopoverHasFocus, + handleClose, + hasEditorFocus, + mediaOpen, + popoverHasFocus, + ]); + + useEffect(() => { + if (!hasEditorFocus || !selection || mediaOpen || popoverHasFocus) { return; } @@ -109,12 +150,24 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE } if (node !== element && node !== firstChild) { - handleClose(); + if (anchorEl) { + handleClose(); + } return; } handleOpenPopover(); - }, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]); + }, [ + handleClose, + hasEditorFocus, + element, + selection, + editor, + handleOpenPopover, + mediaOpen, + popoverHasFocus, + anchorEl, + ]); return ( @@ -140,6 +193,9 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE onMediaChange={handleMediaChange} onRemove={handleRemove} forImage + onFocus={handlePopoverFocus} + onBlur={handlePopoverBlur} + onMediaToggle={handleMediaToggle} /> {children} diff --git a/packages/core/src/widgets/markdown/plate/components/nodes/link/withLinkElement.tsx b/packages/core/src/widgets/markdown/plate/components/nodes/link/withLinkElement.tsx index 1e1389a2..268e0eea 100644 --- a/packages/core/src/widgets/markdown/plate/components/nodes/link/withLinkElement.tsx +++ b/packages/core/src/widgets/markdown/plate/components/nodes/link/withLinkElement.tsx @@ -2,17 +2,21 @@ import { findNodePath, focusEditor, getEditorString, + getNode, + replaceNodeChildren, setNodes, unwrapLink, - upsertLink, + usePlateSelection, } from '@udecode/plate'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useFocused } from 'slate-react'; +import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import MediaPopover from '../../common/MediaPopover'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { MdLinkElement, MdValue } from '@staticcms/markdown'; -import type { PlateRenderElementProps } from '@udecode/plate'; +import type { PlateRenderElementProps, TText } from '@udecode/plate'; import type { FC, MouseEvent } from 'react'; export interface WithLinkElementProps { @@ -24,19 +28,49 @@ export interface WithLinkElementProps { const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => { const LinkElement: FC> = ({ - attributes, + attributes: { ref: _ref, ...attributes }, children, nodeProps, element, editor, }) => { - const [anchorEl, setAnchorEl] = useState(null); + const urlRef = useRef(null); const { url } = element; const path = findNodePath(editor, element); const [internalUrl, setInternalUrl] = useState(url); const [internalText, setInternalText] = useState(getEditorString(editor, path)); + const [popoverHasFocus, setPopoverHasFocus] = useState(false); + const debouncedPopoverHasFocus = useDebounce(popoverHasFocus, 100); + + const [mediaOpen, setMediaOpen] = useState(false); + + const [anchorEl, setAnchorEl] = useState(null); + const hasEditorFocus = useFocused(); + const debouncedHasEditorFocus = useDebounce(hasEditorFocus, 100); + + const handleOpenPopover = useCallback(() => { + setAnchorEl(urlRef.current); + }, []); + + const handleBlur = useCallback(() => { + if (!popoverHasFocus && !mediaOpen) { + setAnchorEl(null); + } + }, [mediaOpen, popoverHasFocus]); + + const handlePopoverFocus = useCallback(() => { + setPopoverHasFocus(true); + }, []); + + const handlePopoverBlur = useCallback(() => { + setPopoverHasFocus(false); + }, []); + + const handleMediaToggle = useCallback(() => { + setMediaOpen(oldMediaOpen => !oldMediaOpen); + }, []); const handleClick = useCallback((event: MouseEvent) => { setAnchorEl(event.currentTarget); @@ -50,11 +84,29 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle focusEditor(editor, editor.selection); }, [editor]); + const selection = usePlateSelection(); + const handleChange = useCallback( (newUrl: string, newText: string) => { const path = findNodePath(editor, element); - path && setNodes(editor, { url: newUrl }, { at: path }); - upsertLink(editor, { url: newUrl, text: newText }); + + if (path) { + setNodes( + editor, + { ...element, url: newUrl, children: [{ text: newText }] }, + { at: path }, + ); + + if (newText?.length && newText !== getEditorString(editor, path)) { + replaceNodeChildren(editor, { + at: path, + nodes: { text: newText }, + insertOptions: { + select: true, + }, + }); + } + } }, [editor, element], ); @@ -72,9 +124,83 @@ const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkEle handleChange(internalUrl, internalText); }, [handleChange, internalText, internalUrl]); + useEffect(() => { + if ( + hasEditorFocus || + debouncedHasEditorFocus || + mediaOpen || + popoverHasFocus || + debouncedPopoverHasFocus + ) { + return; + } + + handleClose(); + }, [ + debouncedHasEditorFocus, + debouncedPopoverHasFocus, + handleClose, + hasEditorFocus, + mediaOpen, + popoverHasFocus, + ]); + useEffect(() => { + if ( + hasEditorFocus || + debouncedHasEditorFocus || + mediaOpen || + popoverHasFocus || + debouncedPopoverHasFocus + ) { + return; + } + + handleClose(); + }, [ + debouncedHasEditorFocus, + debouncedPopoverHasFocus, + handleClose, + hasEditorFocus, + mediaOpen, + popoverHasFocus, + ]); + + useEffect(() => { + if (!hasEditorFocus || !selection || mediaOpen || popoverHasFocus) { + return; + } + + const node = getNode(editor, selection.anchor.path); + const firstChild = + 'children' in element && element.children.length > 0 ? element.children[0] : undefined; + + if (!node) { + return; + } + + if (node !== element && node !== firstChild) { + if (anchorEl) { + handleClose(); + } + return; + } + + handleOpenPopover(); + }, [ + handleClose, + hasEditorFocus, + element, + selection, + editor, + handleOpenPopover, + mediaOpen, + popoverHasFocus, + anchorEl, + ]); + return ( - <> - + + {children} - + ); }; diff --git a/packages/core/src/widgets/object/ObjectControl.tsx b/packages/core/src/widgets/object/ObjectControl.tsx index 8959106d..d3c5fd4c 100644 --- a/packages/core/src/widgets/object/ObjectControl.tsx +++ b/packages/core/src/widgets/object/ObjectControl.tsx @@ -1,7 +1,7 @@ import { styled } from '@mui/material/styles'; import React, { useCallback, useMemo, useState } from 'react'; -import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/EditorControl'; +import EditorControl from '@staticcms/core/components/editor/EditorControlPane/EditorControl'; import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar'; import Outline from '@staticcms/core/components/UI/Outline'; import { transientOptions } from '@staticcms/core/lib'; diff --git a/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx b/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx index 9a8ace04..ca56807d 100644 --- a/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx +++ b/packages/core/src/widgets/object/__tests__/ObjectControl.spec.tsx @@ -35,7 +35,7 @@ const ObjectControlWrapper = createControlWrapper({ path: 'object', }); -jest.mock('@staticcms/core/components/Editor/EditorControlPane/EditorControl', () => { +jest.mock('@staticcms/core/components/editor/EditorControlPane/EditorControl', () => { return jest.fn(props => { const { parentPath, fieldName, field } = props; return (