diff --git a/packages/volto/cypress/tests/core/controlpanels/dexterity-controlpanel-layout.js b/packages/volto/cypress/tests/core/controlpanels/dexterity-controlpanel-layout.js index 20b1a097e6..9337d8cff4 100644 --- a/packages/volto/cypress/tests/core/controlpanels/dexterity-controlpanel-layout.js +++ b/packages/volto/cypress/tests/core/controlpanels/dexterity-controlpanel-layout.js @@ -27,6 +27,8 @@ describe('ControlPanel: Dexterity Content-Types Layout', () => { ); cy.get('#page-controlpanel-layout button').click(); + cy.get('#sidebar .formtabs').contains('Settings').click(); + // Wait a bit for draftjs to load, without this the title block // custom placeholder is missing and cypress gives a timeout error cy.wait(1000); diff --git a/packages/volto/locales/ca/LC_MESSAGES/volto.po b/packages/volto/locales/ca/LC_MESSAGES/volto.po index c5d1440d68..4dfefdc7ae 100644 --- a/packages/volto/locales/ca/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ca/LC_MESSAGES/volto.po @@ -2481,6 +2481,11 @@ msgstr "Obre el menú" msgid "Open object browser" msgstr "Obre el navegador d'objectes" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/de/LC_MESSAGES/volto.po b/packages/volto/locales/de/LC_MESSAGES/volto.po index 05460062ad..58c6b0ea39 100644 --- a/packages/volto/locales/de/LC_MESSAGES/volto.po +++ b/packages/volto/locales/de/LC_MESSAGES/volto.po @@ -2480,6 +2480,11 @@ msgstr "Menü öffnen" msgid "Open object browser" msgstr "Objekt-Browser öffnen" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/en/LC_MESSAGES/volto.po b/packages/volto/locales/en/LC_MESSAGES/volto.po index 99ff970059..8df419fde2 100644 --- a/packages/volto/locales/en/LC_MESSAGES/volto.po +++ b/packages/volto/locales/en/LC_MESSAGES/volto.po @@ -2475,6 +2475,11 @@ msgstr "" msgid "Open object browser" msgstr "" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/es/LC_MESSAGES/volto.po b/packages/volto/locales/es/LC_MESSAGES/volto.po index 323c212b87..1fcb01ff3e 100644 --- a/packages/volto/locales/es/LC_MESSAGES/volto.po +++ b/packages/volto/locales/es/LC_MESSAGES/volto.po @@ -2482,6 +2482,11 @@ msgstr "Abrir menú" msgid "Open object browser" msgstr "Abrir buscador de objetos" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/eu/LC_MESSAGES/volto.po b/packages/volto/locales/eu/LC_MESSAGES/volto.po index 49936fe350..4b49744385 100644 --- a/packages/volto/locales/eu/LC_MESSAGES/volto.po +++ b/packages/volto/locales/eu/LC_MESSAGES/volto.po @@ -2482,6 +2482,11 @@ msgstr "Menua ireki" msgid "Open object browser" msgstr "Ireki elementu bilatzailea" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/fi/LC_MESSAGES/volto.po b/packages/volto/locales/fi/LC_MESSAGES/volto.po index ea67919876..a3303a3cb3 100644 --- a/packages/volto/locales/fi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fi/LC_MESSAGES/volto.po @@ -2480,6 +2480,11 @@ msgstr "Avaa valikko" msgid "Open object browser" msgstr "" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/fr/LC_MESSAGES/volto.po b/packages/volto/locales/fr/LC_MESSAGES/volto.po index 422561a85a..e5ac29e9c8 100644 --- a/packages/volto/locales/fr/LC_MESSAGES/volto.po +++ b/packages/volto/locales/fr/LC_MESSAGES/volto.po @@ -2482,6 +2482,11 @@ msgstr "Ouvrir le menu" msgid "Open object browser" msgstr "Ouvrir le navigateur d'objets" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/hi/LC_MESSAGES/volto.po b/packages/volto/locales/hi/LC_MESSAGES/volto.po index aa80505383..6e674c809f 100644 --- a/packages/volto/locales/hi/LC_MESSAGES/volto.po +++ b/packages/volto/locales/hi/LC_MESSAGES/volto.po @@ -2475,6 +2475,11 @@ msgstr "मेन्यू खोलें" msgid "Open object browser" msgstr "ऑब्जेक्ट ब्राउज़र खोलें" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/it/LC_MESSAGES/volto.po b/packages/volto/locales/it/LC_MESSAGES/volto.po index 856f036ffe..f677738b2b 100644 --- a/packages/volto/locales/it/LC_MESSAGES/volto.po +++ b/packages/volto/locales/it/LC_MESSAGES/volto.po @@ -2475,6 +2475,11 @@ msgstr "Apri menu" msgid "Open object browser" msgstr "Apri object browser" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "Ordine" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/ja/LC_MESSAGES/volto.po b/packages/volto/locales/ja/LC_MESSAGES/volto.po index 2a2da939ab..2189aef254 100644 --- a/packages/volto/locales/ja/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ja/LC_MESSAGES/volto.po @@ -2480,6 +2480,11 @@ msgstr "メニューを開く" msgid "Open object browser" msgstr "オブジェクトブラウザを開く" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/nl/LC_MESSAGES/volto.po b/packages/volto/locales/nl/LC_MESSAGES/volto.po index 01dc20ba85..6b37102de2 100644 --- a/packages/volto/locales/nl/LC_MESSAGES/volto.po +++ b/packages/volto/locales/nl/LC_MESSAGES/volto.po @@ -2479,6 +2479,11 @@ msgstr "" msgid "Open object browser" msgstr "" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/pt/LC_MESSAGES/volto.po b/packages/volto/locales/pt/LC_MESSAGES/volto.po index 7d02e71e72..cc5ca6584a 100644 --- a/packages/volto/locales/pt/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt/LC_MESSAGES/volto.po @@ -2480,6 +2480,11 @@ msgstr "Abrir menu" msgid "Open object browser" msgstr "" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po b/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po index f2ea3de844..2a17a9bc2d 100644 --- a/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po +++ b/packages/volto/locales/pt_BR/LC_MESSAGES/volto.po @@ -2481,6 +2481,11 @@ msgstr "Abrir menu" msgid "Open object browser" msgstr "Abrir navegador de objetos" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/ro/LC_MESSAGES/volto.po b/packages/volto/locales/ro/LC_MESSAGES/volto.po index 9481755aa6..811ea97670 100644 --- a/packages/volto/locales/ro/LC_MESSAGES/volto.po +++ b/packages/volto/locales/ro/LC_MESSAGES/volto.po @@ -2475,6 +2475,11 @@ msgstr "Deschideți meniul" msgid "Open object browser" msgstr "Deschideți browserul de obiecte" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/volto.pot b/packages/volto/locales/volto.pot index c28b4ea5dc..f96213d3b0 100644 --- a/packages/volto/locales/volto.pot +++ b/packages/volto/locales/volto.pot @@ -2477,6 +2477,11 @@ msgstr "" msgid "Open object browser" msgstr "" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po b/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po index d4511f9c05..fda490e0f4 100644 --- a/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po +++ b/packages/volto/locales/zh_CN/LC_MESSAGES/volto.po @@ -2481,6 +2481,11 @@ msgstr "打开菜单" msgid "Open object browser" msgstr "打开目标浏览器" +#. Default: "Order" +#: components/manage/Sidebar/Sidebar +msgid "Order" +msgstr "" + #. Default: "Ordered" #: components/manage/Blocks/ToC/Schema msgid "Ordered" diff --git a/packages/volto/news/5642.feature b/packages/volto/news/5642.feature new file mode 100644 index 0000000000..e3af830c77 --- /dev/null +++ b/packages/volto/news/5642.feature @@ -0,0 +1 @@ +Added blocks layout navigator @robgietema @sneridagh \ No newline at end of file diff --git a/packages/volto/package.json b/packages/volto/package.json index 2a2b014138..ca3d4364bd 100644 --- a/packages/volto/package.json +++ b/packages/volto/package.json @@ -283,6 +283,9 @@ "@babel/types": "7.20.5", "@fiverr/afterbuild-webpack-plugin": "^1.0.0", "@jest/globals": "^29.7.0", + "@dnd-kit/core": "6.0.8", + "@dnd-kit/sortable": "7.0.2", + "@dnd-kit/utilities": "3.2.2", "@loadable/babel-plugin": "5.13.2", "@loadable/webpack-plugin": "5.15.2", "@plone/types": "workspace:*", diff --git a/packages/volto/src/actions/form/form.js b/packages/volto/src/actions/form/form.js index 5cc22aabc3..bfbcc75bf4 100644 --- a/packages/volto/src/actions/form/form.js +++ b/packages/volto/src/actions/form/form.js @@ -3,13 +3,16 @@ * @module actions/form/form */ -import { SET_FORM_DATA } from '@plone/volto/constants/ActionTypes'; +import { + SET_FORM_DATA, + SET_UI_STATE, +} from '@plone/volto/constants/ActionTypes'; /** * Set form data function. * @function setFormData * @param {Object} data New form data. - * @returns {Object} Set sidebar action. + * @returns {Object} Set form data action. */ export function setFormData(data) { return { @@ -17,3 +20,16 @@ export function setFormData(data) { data, }; } + +/** + * Set ui state function. + * @function setUIState + * @param {Object} ui New ui state. + * @returns {Object} Set ui state action. + */ +export function setUIState(ui) { + return { + type: SET_UI_STATE, + ui, + }; +} diff --git a/packages/volto/src/actions/index.js b/packages/volto/src/actions/index.js index 21c58372de..ee88c10b60 100644 --- a/packages/volto/src/actions/index.js +++ b/packages/volto/src/actions/index.js @@ -157,7 +157,7 @@ export { resetMetadataFocus, setSidebarTab, } from '@plone/volto/actions/sidebar/sidebar'; -export { setFormData } from '@plone/volto/actions/form/form'; +export { setFormData, setUIState } from '@plone/volto/actions/form/form'; export { deleteLinkTranslation, getTranslationLocator, diff --git a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx index 0424a307bc..a5fb57e217 100644 --- a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.jsx @@ -1,11 +1,14 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; +import { cloneDeep, map } from 'lodash'; import EditBlock from './Edit'; import { DragDropList } from '@plone/volto/components'; import { getBlocks, getBlocksFieldname, + getBlocksLayoutFieldname, applyBlockDefaults, + getBlocksHierarchy, } from '@plone/volto/helpers'; import { addBlock, @@ -13,15 +16,19 @@ import { changeBlock, deleteBlock, moveBlock, + moveBlockEnhanced, mutateBlock, nextBlockId, previousBlockId, } from '@plone/volto/helpers'; import EditBlockWrapper from './EditBlockWrapper'; -import { setSidebarTab } from '@plone/volto/actions'; +import { setSidebarTab, setUIState } from '@plone/volto/actions'; import { useDispatch } from 'react-redux'; import { useDetectClickOutside, useEvent } from '@plone/volto/helpers'; import config from '@plone/volto/registry'; +import { createPortal } from 'react-dom'; + +import Order from './Order/Order'; const BlocksForm = (props) => { const { @@ -53,6 +60,12 @@ const BlocksForm = (props) => { token, } = props; + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + const blockList = getBlocks(properties); const dispatch = useDispatch(); @@ -183,6 +196,52 @@ const BlocksForm = (props) => { onChangeFormData(newFormData); }; + const onMoveBlockEnhanced = ({ source, destination }) => { + const newFormData = moveBlockEnhanced(cloneDeep(properties), { + source, + destination, + }); + const blocksFieldname = getBlocksFieldname(newFormData); + const blocksLayoutFieldname = getBlocksLayoutFieldname(newFormData); + let error = false; + + const allowedBlocks = Object.keys(blocksConfig); + + map(newFormData[blocksLayoutFieldname].items, (id) => { + const block = newFormData[blocksFieldname][id]; + if (!allowedBlocks.includes(block['@type'])) { + error = true; + } + if (Array.isArray(block[blocksLayoutFieldname]?.items)) { + const size = block[blocksLayoutFieldname].items.length; + const allowedSubBlocks = [ + ...(blocksConfig[block['@type']].allowedBlocks || allowedBlocks), + 'empty', + ] || ['empty']; + if (size < 1 || size > (blocksConfig[block['@type']].maxLength || 4)) { + error = true; + } + map(block[blocksLayoutFieldname].items, (subId) => { + const subBlock = block[blocksFieldname][subId]; + if (!allowedSubBlocks.includes(subBlock['@type'])) { + error = true; + } + }); + } + }); + + if (!error) { + onChangeFormData(newFormData); + dispatch( + setUIState({ + selected: null, + multiSelected: [], + gridSelected: null, + }), + ); + } + }; + const defaultBlockWrapper = ({ draginfo }, editBlock, blockProps) => ( {editBlock} @@ -195,6 +254,7 @@ const BlocksForm = (props) => { // Note they are alreaady filtered by DragDropList, but we also want them // to be removed when the user saves the page next. Otherwise the invalid // blocks would linger for ever. + for (const [n, v] of blockList) { if (!v) { const newFormData = deleteBlock(properties, n); @@ -210,85 +270,101 @@ const BlocksForm = (props) => { }); return ( -
{ - if (stopPropagation) { - e.stopPropagation(); - } - }} - > -
- { - const { source, destination } = result; - if (!destination) { - return; - } - const newFormData = moveBlock( - properties, - source.index, - destination.index, - ); - onChangeFormData(newFormData); - return true; - }} - direction={direction} - > - {(dragProps) => { - const { child, childId, index } = dragProps; - const blockProps = { - allowedBlocks, - showRestricted, - block: childId, - data: child, - handleKeyDown, - id: childId, - formTitle: title, - formDescription: description, - index, - manage, - onAddBlock, - onInsertBlock, - onChangeBlock, - onChangeField, - onChangeFormData, - onDeleteBlock, - onFocusNextBlock, - onFocusPreviousBlock, - onMoveBlock, - onMutateBlock, - onSelectBlock, - pathname, - metadata, - properties, - contentType: type, - navRoot, - blocksConfig, - selected: selectedBlock === childId, - multiSelected: multiSelected?.includes(childId), - type: child['@type'], - editable, - showBlockChooser: selectedBlock === childId, - detached: isContainer, - // Properties to pass to the BlocksForm to match the View ones - content: properties, - history, - location, - token, - }; - return editBlockWrapper( - dragProps, - , - blockProps, - ); - }} - -
-
+ <> + {isMainForm && + isClient && + createPortal( +
+ +
, + document.getElementById('sidebar-order'), + )} +
{ + if (stopPropagation) { + e.stopPropagation(); + } + }} + > +
+ { + const { source, destination } = result; + if (!destination) { + return; + } + const newFormData = moveBlock( + properties, + source.index, + destination.index, + ); + onChangeFormData(newFormData); + return true; + }} + direction={direction} + > + {(dragProps) => { + const { child, childId, index } = dragProps; + const blockProps = { + allowedBlocks, + showRestricted, + block: childId, + data: child, + handleKeyDown, + id: childId, + formTitle: title, + formDescription: description, + index, + manage, + onAddBlock, + onInsertBlock, + onChangeBlock, + onChangeField, + onChangeFormData, + onDeleteBlock, + onFocusNextBlock, + onFocusPreviousBlock, + onMoveBlock, + onMutateBlock, + onSelectBlock, + pathname, + metadata, + properties, + contentType: type, + navRoot, + blocksConfig, + selected: selectedBlock === childId, + multiSelected: multiSelected?.includes(childId), + type: child['@type'], + editable, + showBlockChooser: selectedBlock === childId, + detached: isContainer, + // Properties to pass to the BlocksForm to match the View ones + content: properties, + history, + location, + token, + }; + return editBlockWrapper( + dragProps, + , + blockProps, + ); + }} + +
+
+ ); }; diff --git a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.test.jsx b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.test.jsx index 55958451af..4f53b162e1 100644 --- a/packages/volto/src/components/manage/Blocks/Block/BlocksForm.test.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/BlocksForm.test.jsx @@ -30,6 +30,9 @@ test('Allow override of blocksConfig', () => { locale: 'en', messages: {}, }, + form: { + ui: {}, + }, }); const data = { @@ -68,6 +71,7 @@ test('Allow override of blocksConfig', () => { const { container } = render( + , ); expect(container).toMatchSnapshot(); @@ -79,6 +83,9 @@ test('Removes invalid blocks on saving', () => { locale: 'en', messages: {}, }, + form: { + ui: {}, + }, }); const onChangeFormData = jest.fn(() => {}); @@ -120,6 +127,7 @@ test('Removes invalid blocks on saving', () => { render( + , ); expect(onChangeFormData).toBeCalledWith({ diff --git a/packages/volto/src/components/manage/Blocks/Block/Edit.jsx b/packages/volto/src/components/manage/Blocks/Block/Edit.jsx index cbcfdf527d..4b9c4419a5 100644 --- a/packages/volto/src/components/manage/Blocks/Block/Edit.jsx +++ b/packages/volto/src/components/manage/Blocks/Block/Edit.jsx @@ -9,7 +9,7 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { defineMessages, injectIntl } from 'react-intl'; import cx from 'classnames'; -import { setSidebarTab } from '@plone/volto/actions'; +import { setSidebarTab, setUIState } from '@plone/volto/actions'; import config from '@plone/volto/registry'; import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; import { applyBlockDefaults } from '@plone/volto/helpers'; @@ -79,7 +79,11 @@ export class Edit extends Component { this.blockNode.current.focus(); } const tab = this.props.manage ? 1 : blocksConfig?.[type]?.sidebarTab || 0; - if (this.props.selected && this.props.editable) { + if ( + this.props.selected && + this.props.editable && + this.props.sidebarTab !== 2 + ) { this.props.setSidebarTab(tab); } } @@ -105,7 +109,9 @@ export class Edit extends Component { const tab = this.props.manage ? 1 : blocksConfig?.[nextProps.type]?.sidebarTab || 0; - this.props.setSidebarTab(tab); + if (this.props.sidebarTab !== 2) { + this.props.setSidebarTab(tab); + } } } @@ -138,6 +144,21 @@ export class Edit extends Component { {Block !== null ? (
{ + if (this.props.hovered !== this.props.id) { + this.props.setUIState({ hovered: this.props.id }); + } + }} + onFocus={() => { + // TODO: This `onFocus` steals somehow the focus from the slate block + // we have to investigate why this is happening + // Apparently, I can't see any difference in the behavior + // If any, we can fix it in successive iterations + // if (this.props.hovered !== this.props.id) { + // this.props.setUIState({ hovered: this.props.id }); + // } + }} + onMouseLeave={() => this.props.setUIState({ hovered: null })} onClick={(e) => { const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; !this.props.selected && @@ -161,6 +182,7 @@ export class Edit extends Component { className={cx('block', type, this.props.data.variation, { selected: this.props.selected || this.props.multiSelected, multiSelected: this.props.multiSelected, + hovered: this.props.hovered === this.props.id, })} style={{ outline: 'none' }} ref={this.blockNode} @@ -185,6 +207,11 @@ export class Edit extends Component { ) : (
+ this.props.setUIState({ hovered: this.props.id }) + } + onFocus={() => this.props.setUIState({ hovered: this.props.id })} + onMouseLeave={() => this.props.setUIState({ hovered: null })} onClick={() => !this.props.selected && this.props.onSelectBlock(this.props.id) } @@ -218,5 +245,11 @@ export class Edit extends Component { export default compose( injectIntl, withObjectBrowser, - connect(null, { setSidebarTab }), + connect( + (state, props) => ({ + hovered: state.form?.ui.hovered || null, + sidebarTab: state.sidebar?.tab, + }), + { setSidebarTab, setUIState }, + ), )(Edit); diff --git a/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx b/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx new file mode 100644 index 0000000000..db73f4fbf7 --- /dev/null +++ b/packages/volto/src/components/manage/Blocks/Block/Order/Item.jsx @@ -0,0 +1,122 @@ +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { includes } from 'lodash'; + +import { Icon } from '@plone/volto/components'; +import { setUIState } from '@plone/volto/actions'; +import config from '@plone/volto/registry'; + +import deleteSVG from '@plone/volto/icons/delete.svg'; +import dragSVG from '@plone/volto/icons/drag.svg'; + +export const Item = forwardRef( + ( + { + clone, + data, + depth, + disableSelection, + disableInteraction, + ghost, + id, + handleProps, + indentationWidth, + onRemove, + onSelectBlock, + parentId, + style, + value, + wrapperRef, + ...props + }, + ref, + ) => { + const selected = useSelector((state) => state.form.ui.selected); + const hovered = useSelector((state) => state.form.ui.hovered); + const multiSelected = useSelector((state) => state.form.ui.multiSelected); + const gridSelected = useSelector((state) => state.form.ui.gridSelected); + const dispatch = useDispatch(); + return ( +
  • dispatch(setUIState({ hovered: id }))} + onFocus={() => dispatch(setUIState({ hovered: id }))} + onMouseLeave={() => dispatch(setUIState({ hovered: null }))} + onClick={(e) => { + if (depth === 0) { + const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; + selected !== id && + onSelectBlock( + id, + selected === id ? false : isMultipleSelection, + e, + ); + } else { + dispatch( + setUIState({ + selected: parentId, + multiSelected: [], + gridSelected: id, + }), + ); + } + }} + ref={wrapperRef} + style={{ + '--spacing': `${indentationWidth * depth}px`, + }} + {...props} + > +
    + + + {config.blocks.blocksConfig[data?.['@type']]?.icon && ( + + )}{' '} + {data?.plaintext || + config.blocks.blocksConfig[data?.['@type']]?.title} + + {!clone && onRemove && ( + + )} +
    +
  • + ); + }, +); diff --git a/packages/volto/src/components/manage/Blocks/Block/Order/Order.jsx b/packages/volto/src/components/manage/Blocks/Block/Order/Order.jsx new file mode 100644 index 0000000000..627efacbed --- /dev/null +++ b/packages/volto/src/components/manage/Blocks/Block/Order/Order.jsx @@ -0,0 +1,367 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { find, min } from 'lodash'; + +import { flattenTree, getProjection, removeChildrenOf } from './utilities'; +import SortableItem from './SortableItem'; + +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; + +export function Order({ + items = [], + onMoveBlock, + onDeleteBlock, + onSelectBlock, + indentationWidth = 25, + removable, + dndKitCore, + dndKitSortable, + dndKitUtilities, +}) { + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + const [offsetLeft, setOffsetLeft] = useState(0); + const [currentPosition, setCurrentPosition] = useState(null); + + const { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + MeasuringStrategy, + defaultDropAnimation, + } = dndKitCore; + const { SortableContext, arrayMove, verticalListSortingStrategy } = + dndKitSortable; + const { CSS } = dndKitUtilities; + + const measuring = { + droppable: { + strategy: MeasuringStrategy.Always, + }, + }; + + const dropAnimationConfig = { + keyframes({ transform }) { + return [ + { opacity: 1, transform: CSS.Transform.toString(transform.initial) }, + { + opacity: 0, + transform: CSS.Transform.toString({ + ...transform.final, + x: transform.final.x + 5, + y: transform.final.y + 5, + }), + }, + ]; + }, + easing: 'ease-out', + sideEffects({ active }) { + active.node.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: defaultDropAnimation.duration, + easing: defaultDropAnimation.easing, + }); + }, + }; + + const flattenedItems = useMemo( + () => removeChildrenOf(flattenTree(items), activeId ? [activeId] : []), + [activeId, items], + ); + const projected = + activeId && overId + ? getProjection( + flattenedItems, + activeId, + overId, + offsetLeft, + indentationWidth, + arrayMove, + ) + : null; + const sensorContext = useRef({ + items: flattenedItems, + offset: offsetLeft, + }); + const sensors = useSensors(useSensor(PointerSensor)); + + const sortedIds = useMemo( + () => flattenedItems.map(({ id }) => id), + [flattenedItems], + ); + const activeItem = activeId + ? flattenedItems.find(({ id }) => id === activeId) + : null; + + useEffect(() => { + sensorContext.current = { + items: flattenedItems, + offset: offsetLeft, + }; + }, [flattenedItems, offsetLeft]); + + const announcements = { + onDragStart({ active }) { + return `Picked up ${active.id}.`; + }, + onDragMove({ active, over }) { + return getMovementAnnouncement('onDragMove', active.id, over?.id); + }, + onDragOver({ active, over }) { + return getMovementAnnouncement('onDragOver', active.id, over?.id); + }, + onDragEnd({ active, over }) { + return getMovementAnnouncement('onDragEnd', active.id, over?.id); + }, + onDragCancel({ active }) { + return `Moving was cancelled. ${active.id} was dropped in its original position.`; + }, + }; + + return ( + + + {flattenedItems.map(({ id, parentId, depth, data }) => ( + handleRemove(id) : undefined} + onSelectBlock={onSelectBlock} + /> + ))} + {createPortal( + + {activeId && activeItem ? ( + + ) : null} + , + document.body, + )} + + + ); + + function handleDragStart({ active: { id: activeId } }) { + setActiveId(activeId); + setOverId(activeId); + + const activeItem = flattenedItems.find(({ id }) => id === activeId); + + if (activeItem) { + setCurrentPosition({ + parentId: activeItem.parentId, + overId: activeId, + }); + } + + document.body.style.setProperty('cursor', 'grabbing'); + } + + function handleDragMove({ delta }) { + setOffsetLeft(delta.x); + } + + function handleDragOver({ over }) { + setOverId(over?.id ?? null); + } + + function handleDragEnd({ active, over }) { + if (projected && over) { + const { depth, parentId } = projected; + const clonedItems = JSON.parse(JSON.stringify(flattenedItems)); + const overIndex = clonedItems.findIndex(({ id }) => id === over.id); + const activeIndex = clonedItems.findIndex(({ id }) => id === active.id); + const activeTreeItem = clonedItems[activeIndex]; + const oldParentId = activeTreeItem.parentId; + + clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }; + + // Translate position depending on parent + if (parentId === oldParentId) { + // Move from and to toplevel or move within the same grid block + + let destIndex = clonedItems[overIndex].index; + if (clonedItems[overIndex].depth > clonedItems[activeIndex].depth) { + destIndex = find(clonedItems, { + id: clonedItems[overIndex].parentId, + }).index; + } + onMoveBlock({ + source: { + position: clonedItems[activeIndex].index, + parent: oldParentId, + id: active.id, + }, + destination: { + position: destIndex, + parent: parentId, + }, + }); + } else if (parentId && oldParentId) { + // Move from one gridblock to another + + onMoveBlock({ + source: { + position: clonedItems[activeIndex].index, + parent: oldParentId, + id: active.id, + }, + destination: { + position: + overIndex < activeIndex + ? clonedItems[overIndex - 1].parentId + ? clonedItems[overIndex - 1].index + 1 + : clonedItems[overIndex].index + : overIndex + 1 < clonedItems.length + ? clonedItems[overIndex + 1].index + : clonedItems[overIndex].index + 1, + parent: parentId, + }, + }); + } else if (oldParentId) { + // Moving to the main container from a gridblock + + onMoveBlock({ + source: { + position: clonedItems[activeIndex].index, + parent: oldParentId, + id: active.id, + }, + destination: { + position: + overIndex > activeIndex + ? overIndex + 1 < clonedItems.length + ? clonedItems[overIndex + 1].index + : clonedItems[overIndex].index + 1 + : clonedItems[overIndex].index, + parent: parentId, + }, + }); + } else { + // Moving from the main container to a gridblock + + onMoveBlock({ + source: { + position: clonedItems[activeIndex].index, + parent: oldParentId, + id: active.id, + }, + destination: { + position: + overIndex < activeIndex + ? clonedItems[overIndex - 1].parentId + ? clonedItems[overIndex - 1].index + 1 + : clonedItems[overIndex].index + : overIndex + 1 < clonedItems.length + ? clonedItems[overIndex + 1].index + : clonedItems[overIndex].index + 1, + parent: parentId, + }, + }); + } + } + + resetState(); + } + + function handleDragCancel() { + resetState(); + } + + function resetState() { + setOverId(null); + setActiveId(null); + setOffsetLeft(0); + setCurrentPosition(null); + + document.body.style.setProperty('cursor', ''); + } + + function handleRemove(id) { + onDeleteBlock(id); + } + + function getMovementAnnouncement(eventName, activeId, overId) { + if (overId && projected) { + if (eventName !== 'onDragEnd') { + if ( + currentPosition && + projected.parentId === currentPosition.parentId && + overId === currentPosition.overId + ) { + return; + } else { + setCurrentPosition({ + parentId: projected.parentId, + overId, + }); + } + } + + const clonedItems = JSON.parse(JSON.stringify(flattenTree(items))); + const overIndex = clonedItems.findIndex(({ id }) => id === overId); + const activeIndex = clonedItems.findIndex(({ id }) => id === activeId); + const sortedItems = arrayMove(clonedItems, activeIndex, overIndex); + + const previousItem = sortedItems[overIndex - 1]; + + let announcement; + const movedVerb = eventName === 'onDragEnd' ? 'dropped' : 'moved'; + const nestedVerb = eventName === 'onDragEnd' ? 'dropped' : 'nested'; + + if (!previousItem) { + const nextItem = sortedItems[overIndex + 1]; + announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`; + } else { + if (projected.depth > previousItem.depth) { + announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`; + } else { + let previousSibling = previousItem; + while (previousSibling && projected.depth < previousSibling.depth) { + const parentId = previousSibling.parentId; + previousSibling = sortedItems.find(({ id }) => id === parentId); + } + + if (previousSibling) { + announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`; + } + } + } + + return announcement; + } + + return; + } +} + +export default injectLazyLibs([ + 'dndKitCore', + 'dndKitSortable', + 'dndKitUtilities', +])(Order); diff --git a/packages/volto/src/components/manage/Blocks/Block/Order/SortableItem.jsx b/packages/volto/src/components/manage/Blocks/Block/Order/SortableItem.jsx new file mode 100644 index 0000000000..bfa328b1e9 --- /dev/null +++ b/packages/volto/src/components/manage/Blocks/Block/Order/SortableItem.jsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; + +import { Item } from './Item'; + +const animateLayoutChanges = ({ isSorting, wasDragging }) => + isSorting || wasDragging ? false : true; + +export function SortableItem({ + id, + depth, + dndKitSortable, + dndKitUtilities, + ...props +}) { + const { useSortable } = dndKitSortable; + const { CSS } = dndKitUtilities; + const { + attributes, + isDragging, + isSorting, + listeners, + setDraggableNodeRef, + setDroppableNodeRef, + transform, + transition, + } = useSortable({ + id, + animateLayoutChanges, + }); + const style = { + transform: CSS.Translate.toString(transform), + transition, + }; + + return ( + + ); +} + +export default injectLazyLibs(['dndKitSortable', 'dndKitUtilities'])( + SortableItem, +); diff --git a/packages/volto/src/components/manage/Blocks/Block/Order/utilities.js b/packages/volto/src/components/manage/Blocks/Block/Order/utilities.js new file mode 100644 index 0000000000..eaa802ca65 --- /dev/null +++ b/packages/volto/src/components/manage/Blocks/Block/Order/utilities.js @@ -0,0 +1,113 @@ +import { isArray } from 'lodash'; + +import { getBlocksLayoutFieldname } from '@plone/volto/helpers'; + +function getDragDepth(offset, indentationWidth) { + return Math.round(offset / indentationWidth); +} + +export function getProjection( + items, + activeId, + overId, + dragOffset, + indentationWidth, + arrayMove, +) { + const overItemIndex = items.findIndex(({ id }) => id === overId); + const activeItemIndex = items.findIndex(({ id }) => id === activeId); + const activeItem = items[activeItemIndex]; + const newItems = arrayMove(items, activeItemIndex, overItemIndex); + const previousItem = newItems[overItemIndex - 1]; + const nextItem = newItems[overItemIndex + 1]; + const dragDepth = getDragDepth(dragOffset, indentationWidth); + const projectedDepth = activeItem.depth + dragDepth; + const maxDepth = getMaxDepth({ + previousItem, + }); + const minDepth = getMinDepth({ nextItem }); + let depth = projectedDepth; + + if (projectedDepth >= maxDepth) { + depth = maxDepth; + } else if (projectedDepth < minDepth) { + depth = minDepth; + } + + return { depth, maxDepth, minDepth, parentId: getParentId() }; + + function getParentId() { + if (depth === 0 || !previousItem) { + return null; + } + + if (depth <= previousItem.depth) { + return previousItem.parentId; + } + + if (depth > previousItem.depth) { + return previousItem.id; + } + + const newParent = newItems + .slice(0, overItemIndex) + .reverse() + .find((item) => item.depth === depth)?.parentId; + + return newParent ?? null; + } +} + +function getMaxDepth({ previousItem }) { + const blocksLayoutFieldname = getBlocksLayoutFieldname( + previousItem?.data || {}, + ); + if (previousItem) { + return isArray(previousItem.data?.[blocksLayoutFieldname]?.items) + ? previousItem.depth + 1 + : previousItem.depth; + } + + return 0; +} + +function getMinDepth({ nextItem }) { + if (nextItem) { + return nextItem.depth; + } + + return 0; +} + +function flatten(items = [], parentId = null, depth = 0) { + return items.reduce((acc, item, index) => { + return [ + ...acc, + { ...item, parentId, depth, index }, + ...flatten(item.children, item.id, depth + 1), + ]; + }, []); +} + +export function flattenTree(items) { + return flatten(items); +} + +export function findItem(items, itemId) { + return items.find(({ id }) => id === itemId); +} + +export function removeChildrenOf(items, ids) { + const excludeParentIds = [...ids]; + + return items.filter((item) => { + if (item.parentId && excludeParentIds.includes(item.parentId)) { + if (item.children.length) { + excludeParentIds.push(item.id); + } + return false; + } + + return true; + }); +} diff --git a/packages/volto/src/components/manage/Blocks/Block/__snapshots__/BlocksForm.test.jsx.snap b/packages/volto/src/components/manage/Blocks/Block/__snapshots__/BlocksForm.test.jsx.snap index 2197f9332e..15c1cb7e5b 100644 --- a/packages/volto/src/components/manage/Blocks/Block/__snapshots__/BlocksForm.test.jsx.snap +++ b/packages/volto/src/components/manage/Blocks/Block/__snapshots__/BlocksForm.test.jsx.snap @@ -114,5 +114,152 @@ exports[`Allow override of blocksConfig 1`] = `
    + `; diff --git a/packages/volto/src/components/manage/Blocks/Container/Edit.jsx b/packages/volto/src/components/manage/Blocks/Container/Edit.jsx index 6c7fa892cb..7c1ce4d9d4 100644 --- a/packages/volto/src/components/manage/Blocks/Container/Edit.jsx +++ b/packages/volto/src/components/manage/Blocks/Container/Edit.jsx @@ -129,6 +129,7 @@ const ContainerBlockEdit = (props) => { blocksConfig={allowedBlocksConfig} title={data.placeholder} isContainer + isMainForm={false} stopPropagation={selectedBlock} disableAddBlockOnEnterKey onSelectBlock={(id) => { diff --git a/packages/volto/src/components/manage/Blocks/Grid/Edit.jsx b/packages/volto/src/components/manage/Blocks/Grid/Edit.jsx index 713ed9a116..4c15f26881 100644 --- a/packages/volto/src/components/manage/Blocks/Grid/Edit.jsx +++ b/packages/volto/src/components/manage/Blocks/Grid/Edit.jsx @@ -1,14 +1,16 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; -import { useState } from 'react'; import ContainerEdit from '../Container/Edit'; +import { useDispatch, useSelector } from 'react-redux'; +import { setUIState } from '@plone/volto/actions'; const GridBlockEdit = (props) => { const { data } = props; const columnsLength = data?.blocks_layout?.items?.length || 0; - const [selectedBlock, setSelectedBlock] = useState(null); + const selectedBlock = useSelector((state) => state.form.ui.gridSelected); + const dispatch = useDispatch(); return (
    { // This is required to enabling a small "in-between" clickable area // for bringing the Grid sidebar alive once you have selected an inner block onClick={(e) => { - if (!e.block) setSelectedBlock(null); + if (!e.block) dispatch(setUIState({ gridSelected: null })); }} role="presentation" > dispatch(setUIState({ gridSelected: id }))} direction="horizontal" />
    diff --git a/packages/volto/src/components/manage/Form/Form.jsx b/packages/volto/src/components/manage/Form/Form.jsx index 1a7d3a072e..b48a1eaffe 100644 --- a/packages/volto/src/components/manage/Form/Form.jsx +++ b/packages/volto/src/components/manage/Form/Form.jsx @@ -52,6 +52,7 @@ import { resetMetadataFocus, setSidebarTab, setFormData, + setUIState, } from '@plone/volto/actions'; import { compose } from 'redux'; import config from '@plone/volto/registry'; @@ -230,13 +231,17 @@ class Form extends Component { this.props.setFormData(formData); } + this.props.setUIState({ + selected: selectedBlock, + multiSelected: [], + hovered: null, + }); + // Set initial state this.state = { formData, initialFormData, errors: {}, - selected: selectedBlock, - multiSelected: [], isClient: false, // Ensure focus remain in field after change inFocus: {}, @@ -263,6 +268,12 @@ class Form extends Component { let errors = {}; let activeIndex = 0; + if (!this.props.isFormSelected && prevProps.isFormSelected) { + this.props.setUIState({ + selected: null, + }); + } + if (requestError && prevProps.requestError !== requestError) { errors = FormValidation.giveServerErrorsToCorrespondingFields(requestError); @@ -376,15 +387,6 @@ class Form extends Component { this.setState({ isClient: true }); } - static getDerivedStateFromProps(props, state) { - let newState = { ...state }; - if (!props.isFormSelected) { - newState.selected = null; - } - - return newState; - } - /** * Change field handler * Remove errors for changed field @@ -439,9 +441,9 @@ class Form extends Component { if (event.shiftKey) { const anchor = - this.state.multiSelected.length > 0 - ? blocks_layout.indexOf(this.state.multiSelected[0]) - : blocks_layout.indexOf(this.state.selected); + this.props.uiState.multiSelected.length > 0 + ? blocks_layout.indexOf(this.props.uiState.multiSelected[0]) + : blocks_layout.indexOf(this.props.uiState.selected); const focus = blocks_layout.indexOf(id); if (anchor === focus) { @@ -451,15 +453,16 @@ class Form extends Component { } else { multiSelected = [...blocks_layout.slice(focus, anchor + 1)]; } + window.getSelection().empty(); } if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { - multiSelected = this.state.multiSelected || []; - if (!this.state.multiSelected.includes(this.state.selected)) { - multiSelected = [...multiSelected, this.state.selected]; + multiSelected = this.props.uiState.multiSelected || []; + if (!this.props.uiState.multiSelected.includes(this.state.selected)) { + multiSelected = [...multiSelected, this.props.uiState.selected]; selected = null; } - if (this.state.multiSelected.includes(id)) { + if (this.props.uiState.multiSelected.includes(id)) { selected = null; multiSelected = without(multiSelected, id); } else { @@ -468,9 +471,10 @@ class Form extends Component { } } - this.setState({ + this.props.setUIState({ selected, multiSelected, + gridSelected: null, }); if (this.props.onSelectForm) { @@ -660,141 +664,143 @@ class Form extends Component { /> - { - const newFormData = { - ...formData, - ...newBlockData, - }; - this.setState({ - formData: newFormData, - }); - if (this.props.global) { - this.props.setFormData(newFormData); - } - }} - onSetSelectedBlocks={(blockIds) => - this.setState({ multiSelected: blockIds }) - } - onSelectBlock={this.onSelectBlock} - /> - { - if (this.props.global) { - this.props.setFormData(state.formData); + <> + { + const newFormData = { + ...formData, + ...newBlockData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onSetSelectedBlocks={(blockIds) => + this.props.setUIState({ multiSelected: blockIds }) } - return this.setState(state); - }} - /> - { - const newFormData = { - ...formData, - ...newData, - }; - this.setState({ - formData: newFormData, - }); - if (this.props.global) { - this.props.setFormData(newFormData); - } - }} - onChangeField={this.onChangeField} - onSelectBlock={this.onSelectBlock} - properties={formData} - navRoot={navRoot} - type={type} - pathname={this.props.pathname} - selectedBlock={this.state.selected} - multiSelected={this.state.multiSelected} - manage={this.props.isAdminForm} - allowedBlocks={this.props.allowedBlocks} - showRestricted={this.props.showRestricted} - editable={this.props.editable} - isMainForm={this.props.editable} - // Properties to pass to the BlocksForm to match the View ones - history={this.props.history} - location={this.props.location} - token={this.props.token} - /> - {this.state.isClient && - this.state.sidebarMetadataIsAvailable && - this.props.editable && - createPortal( - 0} - > - {schema && - map(schema.fieldsets, (fieldset) => ( - -
    + { + if (this.props.global) { + this.props.setFormData(state.formData); + } + return this.setState(state); + }} + /> + { + const newFormData = { + ...formData, + ...newData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onChangeField={this.onChangeField} + onSelectBlock={this.onSelectBlock} + properties={formData} + navRoot={navRoot} + type={type} + pathname={this.props.pathname} + selectedBlock={this.props.uiState.selected} + multiSelected={this.props.uiState.multiSelected} + manage={this.props.isAdminForm} + allowedBlocks={this.props.allowedBlocks} + showRestricted={this.props.showRestricted} + editable={this.props.editable} + isMainForm={this.props.editable} + // Properties to pass to the BlocksForm to match the View ones + history={this.props.history} + location={this.props.location} + token={this.props.token} + /> + {this.state.isClient && + this.state.sidebarMetadataIsAvailable && + this.props.editable && + createPortal( + 0} + > + {schema && + map(schema.fieldsets, (fieldset) => ( + - - {fieldset.title} - {metadataFieldsets.includes(fieldset.id) ? ( - - ) : ( - - )} - - - - {map(fieldset.fields, (field, index) => ( - - ))} - - -
    -
    - ))} -
    , - document.getElementById('sidebar-metadata'), - )} - - + + {fieldset.title} + {metadataFieldsets.includes(fieldset.id) ? ( + + ) : ( + + )} + + + + {map(fieldset.fields, (field, index) => ( + + ))} + + + + + ))} + , + document.getElementById('sidebar-metadata'), + )} + + +
    ) @@ -964,6 +970,7 @@ export default compose( (state, props) => ({ content: state.content.data, globalData: state.form?.global, + uiState: state.form?.ui, metadataFieldsets: state.sidebar?.metadataFieldsets, metadataFieldFocus: state.sidebar?.metadataFieldFocus, }), @@ -971,6 +978,7 @@ export default compose( setMetadataFieldsets, setSidebarTab, setFormData, + setUIState, resetMetadataFocus, }, null, diff --git a/packages/volto/src/components/manage/Sidebar/Sidebar.jsx b/packages/volto/src/components/manage/Sidebar/Sidebar.jsx index 790cb8bc94..57a7badc7d 100644 --- a/packages/volto/src/components/manage/Sidebar/Sidebar.jsx +++ b/packages/volto/src/components/manage/Sidebar/Sidebar.jsx @@ -34,12 +34,23 @@ const messages = defineMessages({ id: 'Expand sidebar', defaultMessage: 'Expand sidebar', }, + order: { + id: 'Order', + defaultMessage: 'Order', + }, }); const Sidebar = (props) => { const dispatch = useDispatch(); const intl = useIntl(); - const { cookies, content, documentTab, blockTab, settingsTab } = props; + const { + cookies, + content, + documentTab, + blockTab, + settingsTab, + orderTab = true, + } = props; const [expanded, setExpanded] = useState( cookies.get('sidebar_expanded') !== 'false', ); @@ -172,6 +183,22 @@ const Sidebar = (props) => { ), }, + !!orderTab && { + menuItem: intl.formatMessage(messages.order), + pane: ( + + + + ), + }, !!settingsTab && { menuItem: intl.formatMessage(messages.settings), pane: ( diff --git a/packages/volto/src/components/manage/Sidebar/__snapshots__/Sidebar.test.jsx.snap b/packages/volto/src/components/manage/Sidebar/__snapshots__/Sidebar.test.jsx.snap index 67187ba13f..429e9a9d30 100644 --- a/packages/volto/src/components/manage/Sidebar/__snapshots__/Sidebar.test.jsx.snap +++ b/packages/volto/src/components/manage/Sidebar/__snapshots__/Sidebar.test.jsx.snap @@ -53,6 +53,12 @@ Array [ > Block + + Order +
    + ,
    import('react-dnd-html5-backend')), reactBeautifulDnd: loadable.lib(() => import('react-beautiful-dnd')), rrule: loadable.lib(() => import('rrule')), + dndKitCore: loadable.lib(() => import('@dnd-kit/core')), + dndKitSortable: loadable.lib(() => import('@dnd-kit/sortable')), + dndKitUtilities: loadable.lib(() => import('@dnd-kit/utilities')), }; diff --git a/packages/volto/src/constants/ActionTypes.js b/packages/volto/src/constants/ActionTypes.js index cb7ced7fe4..14a765d32b 100644 --- a/packages/volto/src/constants/ActionTypes.js +++ b/packages/volto/src/constants/ActionTypes.js @@ -145,4 +145,5 @@ export const RESET_LOGIN_REQUEST = 'RESET_LOGIN_REQUEST'; export const GET_SITE = 'GET_SITE'; export const GET_NAVROOT = 'GET_NAVROOT'; export const SET_FORM_DATA = 'SET_FORM_DATA'; +export const SET_UI_STATE = 'SET_UI_STATE'; export const UPDATE_UPLOADED_FILES = 'UPDATE_UPLOADED_FILES'; diff --git a/packages/volto/src/helpers/Blocks/Blocks.js b/packages/volto/src/helpers/Blocks/Blocks.js index 525c2a1c63..e60f88c608 100644 --- a/packages/volto/src/helpers/Blocks/Blocks.js +++ b/packages/volto/src/helpers/Blocks/Blocks.js @@ -7,7 +7,11 @@ import { omit, without, endsWith, find, isObject, keys, merge } from 'lodash'; import move from 'lodash-move'; import { v4 as uuid } from 'uuid'; import config from '@plone/volto/registry'; -import { applySchemaEnhancer } from '@plone/volto/helpers'; +import { + applySchemaEnhancer, + insertInArray, + removeFromArray, +} from '@plone/volto/helpers'; /** * Get blocks field. @@ -714,6 +718,183 @@ export function findBlocks(blocks, types, result = []) { return result; } +export const getBlocksHierarchy = (properties) => { + const blocksFieldName = getBlocksFieldname(properties); + const blocksLayoutFieldname = getBlocksLayoutFieldname(properties); + return properties[blocksLayoutFieldname]?.items?.map((n) => ({ + id: n, + title: properties[blocksFieldName][n]?.['@type'], + data: properties[blocksFieldName][n], + children: + properties[blocksFieldName][n]?.['@type'] === 'gridBlock' + ? getBlocksHierarchy(properties[blocksFieldName][n]) + : [], + })); +}; + +/** + * Move block to different location index within blocks_layout + * @function moveBlock + * @param {Object} formData Form data + * @param {number} source index within form blocks_layout items + * @param {number} destination index within form blocks_layout items + * @return {Object} New form data + */ +export function moveBlockEnhanced(formData, { source, destination }) { + const blocksLayoutFieldname = getBlocksLayoutFieldname(formData); + const blocksFieldName = getBlocksFieldname(formData); + + // If either one of source and destination are present + // (Moves intra-container or container <-> main container) + if (source.parent || destination.parent) { + // Move from a container to the main container + if (source.parent && !destination.parent) { + let clonedFormData = { ...formData }; + + clonedFormData[blocksFieldName][source.id] = + formData[blocksFieldName][source.parent][blocksFieldName][source.id]; + + clonedFormData[blocksLayoutFieldname].items = insertInArray( + formData[blocksLayoutFieldname].items, + source.id, + destination.position, + ); + + // Remove the source block from the source parent + const sourceContainer = findContainer(clonedFormData, { + containerId: source.parent, + }); + delete sourceContainer[blocksFieldName][source.id]; + sourceContainer[blocksLayoutFieldname].items = removeFromArray( + sourceContainer[blocksLayoutFieldname].items, + source.position, + ); + + return clonedFormData; + } + + // Move from the main container to an inner container + if (!source.parent && destination.parent) { + let clonedFormData = { ...formData }; + + const destinationContainer = findContainer(clonedFormData, { + containerId: destination.parent, + }); + destinationContainer[blocksFieldName][source.id] = + clonedFormData[blocksFieldName][source.id]; + destinationContainer[blocksLayoutFieldname].items = insertInArray( + destinationContainer[blocksLayoutFieldname].items, + source.id, + destination.position, + ); + + // Remove the source block from the source parent + delete clonedFormData[blocksFieldName][source.id]; + clonedFormData[blocksLayoutFieldname].items = removeFromArray( + clonedFormData[blocksLayoutFieldname].items, + source.position, + ); + + return clonedFormData; + } + + // Move within the same container (except moves within the main container) + if (source.parent === destination.parent) { + let clonedFormData = { ...formData }; + + const destinationContainer = findContainer(clonedFormData, { + containerId: destination.parent, + }); + + destinationContainer[blocksLayoutFieldname].items = move( + destinationContainer[blocksLayoutFieldname].items, + source.position, + destination.position, + ); + return clonedFormData; + } + + // Move between containers + if (source.parent !== destination.parent) { + let clonedFormData = { ...formData }; + + const destinationContainer = findContainer(clonedFormData, { + containerId: destination.parent, + }); + destinationContainer[blocksFieldName][source.id] = + formData[blocksFieldName][source.parent][blocksFieldName][source.id]; + + destinationContainer[blocksLayoutFieldname].items = insertInArray( + destinationContainer[blocksLayoutFieldname].items, + source.id, + destination.position, + ); + + // Remove the source block from the source parent + const sourceContainer = findContainer(clonedFormData, { + containerId: source.parent, + }); + delete sourceContainer[blocksFieldName][source.id]; + sourceContainer[blocksLayoutFieldname].items = removeFromArray( + sourceContainer[blocksLayoutFieldname].items, + source.position, + ); + + return clonedFormData; + } + } + + // Default catch all, no source/destination parent specified + // Move within the main container + return { + ...formData, + [blocksLayoutFieldname]: { + items: move( + formData[blocksLayoutFieldname].items, + source.position, + destination.position, + ), + }, + }; +} + +/** + * Finds the container with the specified containerId in the given formData. + * + * @param {object} formData - The form data object. + * @param {object} options - The options object. + * @param {string} options.containerId - The ID of the container to find. + * @returns {object|undefined} - The container object if found, otherwise undefined. + */ +export const findContainer = (formData, { containerId }) => { + if ( + formData.blocks[containerId] && + Object.keys(formData.blocks[containerId]).includes('blocks') && + Object.keys(formData.blocks[containerId]).includes('blocks_layout') + ) { + return formData.blocks[containerId]; + } + + let container; + Object.keys(formData.blocks).every((blockId) => { + const block = formData.blocks[blockId]; + if ( + formData.blocks[blockId] && + Object.keys(formData.blocks[blockId]).includes('blocks') && + Object.keys(formData.blocks[blockId]).includes('blocks_layout') + ) { + container = findContainer(block, { containerId }); + } + if (container) { + return false; + } else { + return true; + } + }); + + return container; +}; + const _dummyIntl = { formatMessage() {}, }; diff --git a/packages/volto/src/helpers/Blocks/Blocks.test.js b/packages/volto/src/helpers/Blocks/Blocks.test.js index c024473e2c..07ca1a96de 100644 --- a/packages/volto/src/helpers/Blocks/Blocks.test.js +++ b/packages/volto/src/helpers/Blocks/Blocks.test.js @@ -22,6 +22,7 @@ import { getPreviousNextBlock, blocksFormGenerator, findBlocks, + findContainer, } from './Blocks'; import config from '@plone/volto/registry'; @@ -1507,3 +1508,138 @@ describe('findBlocks', () => { expect(findBlocks(blocks, types)).toStrictEqual(['3', '4', '8', '9']); }); }); + +describe('findContainer', () => { + const blocksData = { blocks: {}, blocks_layout: { items: [] } }; + + it('Get a container in the first level (main block container)', () => { + const formData = { + title: 'Example', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 3: { title: 'description', '@type': 'description' }, + 4: { title: 'a container', '@type': 'container', ...blocksData }, + }, + blocks_layout: { + items: ['1', '2', '3', '4'], + }, + }; + + expect(findContainer(formData, { containerId: '4' })).toStrictEqual({ + title: 'a container', + '@type': 'container', + ...blocksData, + }); + }); + + it('Get a container in the second level', () => { + const formData = { + title: 'Example', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 3: { title: 'description', '@type': 'description' }, + 4: { + title: 'a container', + '@type': 'container', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 'second-level': { + title: 'a container', + '@type': 'container', + blocks: {}, + blocks_layout: { items: [] }, + }, + }, + blocks_layout: { items: [1, 2, 'second-level'] }, + }, + }, + blocks_layout: { + items: ['1', '2', '3', '4'], + }, + }; + + expect( + findContainer(formData, { containerId: 'second-level' }), + ).toStrictEqual({ + title: 'a container', + '@type': 'container', + blocks: {}, + blocks_layout: { items: [] }, + }); + }); + + it('Get a container in the third level', () => { + const formData = { + title: 'Example', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 3: { title: 'description', '@type': 'description' }, + 4: { + title: 'a container', + '@type': 'container', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 'second-level': { + title: 'a second level container', + '@type': 'container', + blocks: { + 'third-level': { + title: 'a third level container', + '@type': 'container', + blocks: {}, + blocks_layout: { items: [] }, + }, + }, + blocks_layout: { items: ['third-level'] }, + }, + }, + blocks_layout: { items: [1, 2, 'second-level'] }, + }, + }, + blocks_layout: { + items: ['1', '2', '3', '4'], + }, + }; + + expect( + findContainer(formData, { containerId: 'third-level' }), + ).toStrictEqual({ + title: 'a third level container', + '@type': 'container', + blocks: {}, + blocks_layout: { items: [] }, + }); + }); + + describe('findContainer then modify it', () => { + const blocksData = { blocks: {}, blocks_layout: { items: [] } }; + + it('Get and modifies a container in the first level (main block container)', () => { + const formData = { + title: 'Example', + blocks: { + 1: { title: 'title', '@type': 'title' }, + 2: { title: 'an image', '@type': 'image' }, + 3: { title: 'description', '@type': 'description' }, + 4: { title: 'a container', '@type': 'container', ...blocksData }, + }, + blocks_layout: { + items: ['1', '2', '3', '4'], + }, + }; + + const container = findContainer(formData, { containerId: '4' }); + container.title = 'Modified the title of the container'; + expect(findContainer(formData, { containerId: '4' })).toStrictEqual({ + title: 'Modified the title of the container', + '@type': 'container', + ...blocksData, + }); + }); + }); +}); diff --git a/packages/volto/src/helpers/index.js b/packages/volto/src/helpers/index.js index efa3d4b9fd..e4530cb3c7 100644 --- a/packages/volto/src/helpers/index.js +++ b/packages/volto/src/helpers/index.js @@ -61,6 +61,8 @@ export { buildStyleObjectFromData, getPreviousNextBlock, findBlocks, + getBlocksHierarchy, + moveBlockEnhanced, } from '@plone/volto/helpers/Blocks/Blocks'; export { getSimpleDefaultBlocks, @@ -98,6 +100,8 @@ export { hasApiExpander, replaceItemOfArray, cloneDeepSchema, + insertInArray, + removeFromArray, arrayRange, reorderArray, isInteractiveElement, diff --git a/packages/volto/src/reducers/form/form.js b/packages/volto/src/reducers/form/form.js index 8c6a59dc5d..7f7dbad3ba 100644 --- a/packages/volto/src/reducers/form/form.js +++ b/packages/volto/src/reducers/form/form.js @@ -4,10 +4,19 @@ * @module reducers/form/form */ -import { SET_FORM_DATA } from '@plone/volto/constants/ActionTypes'; +import { + SET_FORM_DATA, + SET_UI_STATE, +} from '@plone/volto/constants/ActionTypes'; const initialState = { global: {}, + ui: { + selected: null, + multiSelected: [], + gridSelected: null, + hovered: null, + }, }; /** @@ -23,6 +32,14 @@ export default function form(state = initialState, action = {}) { ...state, global: action.data, }; + case SET_UI_STATE: + return { + ...state, + ui: { + ...state.ui, + ...action.ui, + }, + }; default: return state; } diff --git a/packages/volto/src/reducers/form/form.test.js b/packages/volto/src/reducers/form/form.test.js index 313763c164..c265dcaa33 100644 --- a/packages/volto/src/reducers/form/form.test.js +++ b/packages/volto/src/reducers/form/form.test.js @@ -3,7 +3,15 @@ import { SET_FORM_DATA } from '@plone/volto/constants/ActionTypes'; describe('Form reducer', () => { it('should return the initial state', () => { - expect(form()).toEqual({ global: {} }); + expect(form()).toEqual({ + global: {}, + ui: { + gridSelected: null, + hovered: null, + multiSelected: [], + selected: null, + }, + }); }); it('should handle SET_FORM_DATA', () => { @@ -14,6 +22,12 @@ describe('Form reducer', () => { }), ).toEqual({ global: { foo: 'bar' }, + ui: { + gridSelected: null, + hovered: null, + multiSelected: [], + selected: null, + }, }); }); }); diff --git a/packages/volto/theme/themes/pastanaga/extras/blocks.less b/packages/volto/theme/themes/pastanaga/extras/blocks.less index 7ebbff26f2..a9b84e7435 100644 --- a/packages/volto/theme/themes/pastanaga/extras/blocks.less +++ b/packages/volto/theme/themes/pastanaga/extras/blocks.less @@ -36,20 +36,21 @@ display: inline-block; } -.block .block.selected::before, -.block .block.selected:hover::before { - border-width: 1px; - border-color: rgba(120, 192, 215, 0.75); -} - [data-slate-editor='true'] { outline: none; } +.block .block.hovered::before, .block .block:hover::before { border-color: rgba(120, 192, 215, 0.375); } +.block .block.selected::before, +.block .block.selected:hover::before { + border-width: 1px; + border-color: rgba(120, 192, 215, 0.75); +} + .block .block.multiSelected::before { z-index: 1; background-color: rgba(120, 192, 215, 0.375); diff --git a/packages/volto/theme/themes/pastanaga/extras/sidebar.less b/packages/volto/theme/themes/pastanaga/extras/sidebar.less index efbd9ce68b..633cc070b2 100644 --- a/packages/volto/theme/themes/pastanaga/extras/sidebar.less +++ b/packages/volto/theme/themes/pastanaga/extras/sidebar.less @@ -504,3 +504,148 @@ .formtabs { flex-wrap: wrap; } + +// Order +.tree-item-wrapper { + box-sizing: border-box; + padding-left: var(--spacing); + margin-bottom: -1px; + list-style: none; + + .text { + overflow: hidden; + flex-grow: 1; + padding-left: 0.5rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.disable-interaction { + pointer-events: none; + } + + &.disable-selection, + &.clone { + .text { + -webkit-user-select: none; + user-select: none; + } + } + + &.clone { + display: inline-block; + width: 375px; + height: 43px; + padding: 0; + pointer-events: none; + + .tree-item { + box-shadow: 0px 15px 15px 0 rgba(34, 33, 81, 0.1); + } + } + + &.ghost { + &.indicator { + position: relative; + z-index: 1; + margin-bottom: -1px; + opacity: 1; + + .tree-item { + position: relative; + height: 8px; + padding: 0; + border-color: #2389ff; + background-color: #56a1f8; + + &:before { + position: absolute; + top: -4px; + left: -8px; + display: block; + width: 12px; + height: 12px; + border: 1px solid #2389ff; + border-radius: 50%; + background-color: #ffffff; + content: ''; + } + + > * { + height: 0; + /* Items are hidden using height and opacity to retain focus */ + opacity: 0; + } + } + } + + &:not(.indicator) { + opacity: 0.5; + } + + .tree-item > * { + background-color: transparent; + box-shadow: none; + } + } + + .tree-item { + position: relative; + display: flex; + box-sizing: border-box; + align-items: center; + padding: var(--vertical-padding) 10px; + border: 1px solid #edf1f2; + background-color: #fff; + color: #222; + cursor: pointer; + --vertical-padding: 10px; + + .delete { + visibility: hidden; + } + + &.depth-0 { + border-left: solid 1px white; + } + + &.hovered { + z-index: 99; + border: solid 1px #69b6fa; + } + + &.selected { + z-index: 99; + border: solid 1px #2996da; + + .delete { + visibility: visible; + } + } + + &.multiSelected { + z-index: 99; + border: solid 1px #2996da; + background-color: rgba(120, 192, 215, 0.375); + } + + .action { + border: 0; + margin-left: 8px; + background-color: transparent; + color: rgba(0, 0, 0, 0.6); + + .icon { + vertical-align: middle; + } + + &.delete { + cursor: pointer; + } + + &.drag { + cursor: grab; + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc748fc4c7..e884ea032c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1989,6 +1989,15 @@ importers: '@babel/types': specifier: 7.20.5 version: 7.20.5 + '@dnd-kit/core': + specifier: 6.0.8 + version: 6.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@dnd-kit/sortable': + specifier: 7.0.2 + version: 7.0.2(@dnd-kit/core@6.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@18.2.0) '@fiverr/afterbuild-webpack-plugin': specifier: ^1.0.0 version: 1.0.0 @@ -3261,8 +3270,30 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dual-bundle/import-meta-resolve@4.0.0': - resolution: {integrity: sha512-ZKXyJeFAzcpKM2kk8ipoGIPUqx9BX52omTGnfwjJvxOCaZTM2wtDK7zN0aIgPRbT9XYAlha0HtmZ+XKteuh0Gw==} + '@dnd-kit/accessibility@3.1.0': + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.0.8': + resolution: {integrity: sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@7.0.2': + resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==} + peerDependencies: + '@dnd-kit/core': ^6.0.7 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@dual-bundle/import-meta-resolve@4.1.0': + resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} '@emotion/babel-plugin@11.11.0': resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} @@ -20533,7 +20564,32 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dual-bundle/import-meta-resolve@4.0.0': {} + '@dnd-kit/accessibility@3.1.0(react@18.2.0)': + dependencies: + react: 18.2.0 + tslib: 2.6.2 + + '@dnd-kit/core@6.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + + '@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)': + dependencies: + '@dnd-kit/core': 6.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + tslib: 2.6.2 + + '@dnd-kit/utilities@3.2.2(react@18.2.0)': + dependencies: + react: 18.2.0 + tslib: 2.6.2 + + '@dual-bundle/import-meta-resolve@4.1.0': {} '@emotion/babel-plugin@11.11.0': dependencies: @@ -32291,7 +32347,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -42106,7 +42162,7 @@ snapshots: '@csstools/css-tokenizer': 2.2.4 '@csstools/media-query-list-parser': 2.1.9(@csstools/css-parser-algorithms@2.6.1(@csstools/css-tokenizer@2.2.4))(@csstools/css-tokenizer@2.2.4) '@csstools/selector-specificity': 3.0.3(postcss-selector-parser@6.0.16) - '@dual-bundle/import-meta-resolve': 4.0.0 + '@dual-bundle/import-meta-resolve': 4.1.0 balanced-match: 2.0.0 colord: 2.9.3 cosmiconfig: 9.0.0(typescript@5.4.5)