diff --git a/README.md b/README.md index 2b87d84..3247950 100644 --- a/README.md +++ b/README.md @@ -190,9 +190,46 @@ will automatically handle click events and show a quanta toolbar when selecting It will allow you to naviate to the parent block (TODO) +Add the `data-block-uid={<>}` attribute to your outer most container of the rendered block html. +The `data-block-uid` requires the block's UID, which you need to provide in the outermost container of the block. + +For example, if you are using ploneClient to fetch `data`, it will be `data.blocks_layout.items[x]`. +Now, Click on your blocks in iframe and the sidebar will show its settings. + +Usage: +```js +// Vanilla JS example to render Blocks + +// Function to create the block list +function createBlockList(data) { + const blockList = document.createElement('ul'); + + data.blocks_layout.items.forEach(id => { + if (data.blocks[id]["@type"] === "slate") { + const slateValue = data.blocks[id].value; + const listItem = document.createElement('li'); + listItem.className = 'blog-list-item'; + listItem.setAttribute('data-block-uid', id); // Set Attribute to enable Clicking on Blocks + + const pre = document.createElement('pre'); + pre.className = 'pre-block'; + pre.textContent = JSON.stringify(slateValue, null, 2); + + listItem.appendChild(pre); + blockList.appendChild(listItem); + } + }); + + document.body.appendChild(blockList); +} + +// Call the function to render the blocks +createBlockList(data); +``` + ### Level 3: Enable Realtime changes while editing -You will need to subscribe to an ```onEditChange``` event that will call the callback with the updated data. +You will need to subscribe to an ```onEditChange``` event that will call the callback with the updated data. The `onEditChange` method listens for changes in the Hydra and triggers a callback with updated data. The 'data' object follows the same format as you get from the [ploneClient](https://6.docs.plone.org/volto/client/quick-start.html?highlight=data#query-or-mutation-options-factories). diff --git a/packages/hydra-js/hydra.js b/packages/hydra-js/hydra.js index 171f0b6..0dc43bb 100644 --- a/packages/hydra-js/hydra.js +++ b/packages/hydra-js/hydra.js @@ -3,9 +3,10 @@ class Bridge { constructor(adminOrigin) { this.adminOrigin = adminOrigin; this.token = null; - this.navigationHandler = null; // Handler for navigation events - this.realTimeDataHandler = null; // Handler for message events - this.blockClickHandler = null; // Handler for block click events + this.navigationHandler = null; + this.realTimeDataHandler = null; + this.blockClickHandler = null; + this.currentlySelectedBlock = null; this.init(); } @@ -29,8 +30,16 @@ class Bridge { // Get the access token from the URL const url = new URL(window.location.href); const access_token = url.searchParams.get('access_token'); - this.token = access_token; - this._setTokenCookie(access_token); + const isEditMode = url.searchParams.get('_edit') === 'true'; // Only when in edit mode + + if (access_token) { + this.token = access_token; + this._setTokenCookie(access_token); + } + + if (isEditMode) { + this.enableBlockClickListener(); + } } } @@ -61,11 +70,24 @@ class Bridge { document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/; domain=${domain};`; } + /** + * Enable the frontend to listen for clicks on blocks to open the settings + */ enableBlockClickListener() { this.blockClickHandler = (event) => { const blockElement = event.target.closest('[data-block-uid]'); if (blockElement) { + // Remove border and button from the previously selected block + if (this.currentlySelectedBlock) { + this.currentlySelectedBlock.classList.remove('volto-hydra--outline'); + } + + // Set the currently selected block + this.currentlySelectedBlock = blockElement; + // Add border to the currently selected block + this.currentlySelectedBlock.classList.add('volto-hydra--outline'); const blockUid = blockElement.getAttribute('data-block-uid'); + window.parent.postMessage( { type: 'OPEN_SETTINGS', uid: blockUid }, this.adminOrigin, @@ -138,15 +160,6 @@ export function onEditChange(callback) { } } -/** - * Enable the frontend to listen for clicks on blocks to open the settings - */ -export function enableBlockClickListener() { - if (bridgeInstance) { - bridgeInstance.enableBlockClickListener(); - } -} - // Make initBridge available globally if (typeof window !== 'undefined') { window.initBridge = initBridge; diff --git a/packages/volto-hydra/src/actions.js b/packages/volto-hydra/src/actions.js new file mode 100644 index 0000000..4360841 --- /dev/null +++ b/packages/volto-hydra/src/actions.js @@ -0,0 +1,8 @@ +import { SET_SELECTED_BLOCK } from './constants'; + +export function setSelectedBlock(uid) { + return { + type: SET_SELECTED_BLOCK, + uid: uid, + }; +} diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index 585b562..cc4b19f 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import Cookies from 'js-cookie'; import './styles.css'; +import { setSelectedBlock } from '../../actions'; /** * Get the default URL from the environment @@ -20,9 +21,14 @@ const getDefualtUrl = () => * @returns {string} URL with the admin params */ const getUrlWithAdminParams = (url, token) => { - return typeof window !== 'undefined' - ? `${url}${window.location.pathname.replace('/edit', '')}?access_token=${token}&_edit=true` - : null; + if (typeof window !== 'undefined') { + if (window.location.pathname.endsWith('/edit')) { + return `${url}${window.location.pathname.replace('/edit', '')}?access_token=${token}&_edit=true`; + } else { + return `${url}${window.location.pathname}?access_token=${token}&_edit=false`; + } + } + return null; }; function isValidUrl(string) { @@ -36,6 +42,7 @@ function isValidUrl(string) { } const Iframe = () => { + const dispatch = useDispatch(); const [url, setUrl] = useState(''); const [src, setSrc] = useState(''); @@ -93,6 +100,12 @@ const Iframe = () => { handleNavigateToUrl(event.data.url); break; + case 'OPEN_SETTINGS': + if (history.location.pathname.endsWith('/edit')) { + dispatch(setSelectedBlock(event.data.uid)); + } + break; + default: break; } @@ -105,7 +118,13 @@ const Iframe = () => { return () => { window.removeEventListener('message', messageHandler); }; - }, [handleNavigateToUrl, initialUrl, token]); + }, [ + dispatch, + handleNavigateToUrl, + history.location.pathname, + initialUrl, + token, + ]); useEffect(() => { if (Object.keys(form).length > 0 && isValidUrl(initialUrl)) { diff --git a/packages/volto-hydra/src/constants.js b/packages/volto-hydra/src/constants.js new file mode 100644 index 0000000..71dfa9e --- /dev/null +++ b/packages/volto-hydra/src/constants.js @@ -0,0 +1 @@ +export const SET_SELECTED_BLOCK = 'SET_SELECTED_BLOCK'; diff --git a/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx new file mode 100644 index 0000000..f799517 --- /dev/null +++ b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.jsx @@ -0,0 +1,226 @@ +/** + * Edit block. + * @module components/manage/Blocks/Block/Edit + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +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 config from '@plone/volto/registry'; +import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; +import { applyBlockDefaults } from '@plone/volto/helpers'; +import { ViewDefaultBlock, EditDefaultBlock } from '@plone/volto/components'; + +import { + SidebarPortal, + BlockSettingsSidebar, + BlockSettingsSchema, +} from '@plone/volto/components'; + +const messages = defineMessages({ + unknownBlock: { + id: 'Unknown Block', + defaultMessage: 'Unknown Block {block}', + }, +}); + +/** + * Edit block class. + * @class Edit + * @extends Component + */ +export class Edit extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + type: PropTypes.string.isRequired, + data: PropTypes.objectOf(PropTypes.any).isRequired, + // properties is mapped to formData, so it's not connected to changes of the object + properties: PropTypes.objectOf(PropTypes.any).isRequired, + selected: PropTypes.bool.isRequired, + multiSelected: PropTypes.bool, + index: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + manage: PropTypes.bool, + onMoveBlock: PropTypes.func.isRequired, + onDeleteBlock: PropTypes.func.isRequired, + editable: PropTypes.bool, + pathname: PropTypes.string.isRequired, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + manage: false, + editable: true, + }; + componentDidMount() { + const { type } = this.props; + const { blocksConfig = config.blocks.blocksConfig } = this.props; + + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + this.props.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + const tab = this.props.manage ? 1 : blocksConfig?.[type]?.sidebarTab || 0; + if (this.props.selected && this.props.editable) { + this.props.setSidebarTab(tab); + } + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { selected, type } = this.props; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + nextProps.selected && + selected !== nextProps.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + if ( + ((!this.props.selected && nextProps.selected) || + type !== nextProps.type) && + this.props.editable + ) { + const tab = this.props.manage + ? 1 + : blocksConfig?.[nextProps.type]?.sidebarTab || 0; + this.props.setSidebarTab(tab); + } + } + + blockNode = React.createRef(); + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { editable, type } = this.props; + + const disableNewBlocks = this.props.data?.disableNewBlocks; + + let Block = blocksConfig?.[type]?.['edit'] || EditDefaultBlock; + if ( + this.props.data?.readOnly || + (!editable && !config.blocks.showEditBlocksInBabelView) + ) { + Block = blocksConfig?.[type]?.['view'] || ViewDefaultBlock; + } + const schema = blocksConfig?.[type]?.['schema'] || BlockSettingsSchema; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + + return ( + <> + {Block !== null ? ( +
{ + const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; + !this.props.selected && + this.props.onSelectBlock( + this.props.id, + this.props.selected ? false : isMultipleSelection, + e, + ); + }} + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx('block', type, this.props.data.variation, { + selected: this.props.selected || this.props.multiSelected, + multiSelected: this.props.multiSelected, + })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + tabIndex={!blockHasOwnFocusManagement ? -1 : null} + > + + {this.props.manage && ( + + + + )} +
+ ) : ( +
+ !this.props.selected && this.props.onSelectBlock(this.props.id) + } + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx(`block ${type}`, { selected: this.props.selected })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + tabIndex={-1} + > + {this.props.intl.formatMessage(messages.unknownBlock, { + block: type, + })} +
+ )} + + ); + } +} + +export default compose( + injectIntl, + withObjectBrowser, + connect( + (state, props) => ({ + selected: state.selectedBlock.uid === props.id, + }), + { setSidebarTab }, + ), +)(Edit); diff --git a/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx new file mode 100644 index 0000000..cbcfdf5 --- /dev/null +++ b/packages/volto-hydra/src/customizations/components/manage/Blocks/Block/Edit.original.jsx @@ -0,0 +1,222 @@ +/** + * Edit block. + * @module components/manage/Blocks/Block/Edit + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +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 config from '@plone/volto/registry'; +import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; +import { applyBlockDefaults } from '@plone/volto/helpers'; +import { ViewDefaultBlock, EditDefaultBlock } from '@plone/volto/components'; + +import { + SidebarPortal, + BlockSettingsSidebar, + BlockSettingsSchema, +} from '@plone/volto/components'; + +const messages = defineMessages({ + unknownBlock: { + id: 'Unknown Block', + defaultMessage: 'Unknown Block {block}', + }, +}); + +/** + * Edit block class. + * @class Edit + * @extends Component + */ +export class Edit extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + type: PropTypes.string.isRequired, + data: PropTypes.objectOf(PropTypes.any).isRequired, + // properties is mapped to formData, so it's not connected to changes of the object + properties: PropTypes.objectOf(PropTypes.any).isRequired, + selected: PropTypes.bool.isRequired, + multiSelected: PropTypes.bool, + index: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + manage: PropTypes.bool, + onMoveBlock: PropTypes.func.isRequired, + onDeleteBlock: PropTypes.func.isRequired, + editable: PropTypes.bool, + pathname: PropTypes.string.isRequired, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + manage: false, + editable: true, + }; + + componentDidMount() { + const { type } = this.props; + const { blocksConfig = config.blocks.blocksConfig } = this.props; + + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + this.props.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + const tab = this.props.manage ? 1 : blocksConfig?.[type]?.sidebarTab || 0; + if (this.props.selected && this.props.editable) { + this.props.setSidebarTab(tab); + } + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { selected, type } = this.props; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + if ( + !blockHasOwnFocusManagement && + nextProps.selected && + selected !== nextProps.selected && + this.blockNode.current + ) { + this.blockNode.current.focus(); + } + if ( + ((!this.props.selected && nextProps.selected) || + type !== nextProps.type) && + this.props.editable + ) { + const tab = this.props.manage + ? 1 + : blocksConfig?.[nextProps.type]?.sidebarTab || 0; + this.props.setSidebarTab(tab); + } + } + + blockNode = React.createRef(); + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { blocksConfig = config.blocks.blocksConfig } = this.props; + const { editable, type } = this.props; + + const disableNewBlocks = this.props.data?.disableNewBlocks; + + let Block = blocksConfig?.[type]?.['edit'] || EditDefaultBlock; + if ( + this.props.data?.readOnly || + (!editable && !config.blocks.showEditBlocksInBabelView) + ) { + Block = blocksConfig?.[type]?.['view'] || ViewDefaultBlock; + } + const schema = blocksConfig?.[type]?.['schema'] || BlockSettingsSchema; + const blockHasOwnFocusManagement = + blocksConfig?.[type]?.['blockHasOwnFocusManagement'] || null; + + return ( + <> + {Block !== null ? ( +
{ + const isMultipleSelection = e.shiftKey || e.ctrlKey || e.metaKey; + !this.props.selected && + this.props.onSelectBlock( + this.props.id, + this.props.selected ? false : isMultipleSelection, + e, + ); + }} + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx('block', type, this.props.data.variation, { + selected: this.props.selected || this.props.multiSelected, + multiSelected: this.props.multiSelected, + })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + tabIndex={!blockHasOwnFocusManagement ? -1 : null} + > + + {this.props.manage && ( + + + + )} +
+ ) : ( +
+ !this.props.selected && this.props.onSelectBlock(this.props.id) + } + onKeyDown={ + !(blockHasOwnFocusManagement || disableNewBlocks) + ? (e) => + this.props.handleKeyDown( + e, + this.props.index, + this.props.id, + this.blockNode.current, + ) + : null + } + className={cx(`block ${type}`, { selected: this.props.selected })} + style={{ outline: 'none' }} + ref={this.blockNode} + // The tabIndex is required for the keyboard navigation + tabIndex={-1} + > + {this.props.intl.formatMessage(messages.unknownBlock, { + block: type, + })} +
+ )} + + ); + } +} + +export default compose( + injectIntl, + withObjectBrowser, + connect(null, { setSidebarTab }), +)(Edit); diff --git a/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx b/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx index 3b7668e..f14a401 100644 --- a/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx +++ b/packages/volto-hydra/src/customizations/components/manage/Form/Form.jsx @@ -57,7 +57,7 @@ import { compose } from 'redux'; import config from '@plone/volto/registry'; import SlotRenderer from '@plone/volto/components/theme/SlotRenderer/SlotRenderer'; import Iframe from '../../../../components/Iframe/View'; - +import './styles.css'; /** * Form container class. * @class Form @@ -700,7 +700,8 @@ class Form extends Component { }} />