diff --git a/README.md b/README.md index 955c28a..f361191 100644 --- a/README.md +++ b/README.md @@ -272,11 +272,15 @@ function handleEditChange(updatedData) { onEditChange(handleEditChange); ``` -### Level 4: Enable Managing Blocks directly on your frontend ([TODO](https://github.com/collective/volto-hydra/issues/4)) +### Level 4: Enable Managing Blocks directly on your frontend -If you completed level 2 & 3 (made blocks clickable and enabled live updates) then the editor will automatically gain the management of blocks on the frontend using the quanta toolbar -- Add blocks ([TODO](https://github.com/collective/volto-hydra/issues/27)) -- remove blocks ([TODO](https://github.com/collective/volto-hydra/issues/26)) +If you completed level 2 & 3 (made blocks clickable and enabled live updates) then the editor will automatically gain the management of blocks on the frontend using the quanta toolbar. + +With Quanta toobar, you can use following features: + +- You can click on '+' Icon (appears at the bottom-right of the container in which you added `data-bloc-uid="<>>"` attribute) to add a block below the current block by choosing a type from BlockChooser popup. +- You can click on three dots icon on Quanta toolbar (appears at the top-left) and it will open up a dropdown menu, you can click on 'Remove' to delete the current block. +- Settings option is yet to be implemented [TODO](https://github.com/collective/volto-hydra/issues/81) - drag and drop blocks ([TODO](https://github.com/collective/volto-hydra/issues/65)) - cut, copy and paste blocks ([TODO](https://github.com/collective/volto-hydra/issues/67)) - and more ([TODO](https://github.com/collective/volto-hydra/issues/4)) diff --git a/packages/hydra-js/hydra.js b/packages/hydra-js/hydra.js index 0dc43bb..1a7002a 100644 --- a/packages/hydra-js/hydra.js +++ b/packages/hydra-js/hydra.js @@ -3,10 +3,14 @@ class Bridge { constructor(adminOrigin) { this.adminOrigin = adminOrigin; this.token = null; - this.navigationHandler = null; - this.realTimeDataHandler = null; - this.blockClickHandler = null; + this.navigationHandler = null; // Handler for navigation events + this.realTimeDataHandler = null; // Handler for message events + this.blockClickHandler = null; // Handler for block click events this.currentlySelectedBlock = null; + this.addButton = null; + this.deleteButton = null; + this.clickOnBtn = false; + this.quantaToolbar = null; this.init(); } @@ -30,8 +34,7 @@ class Bridge { // Get the access token from the URL const url = new URL(window.location.href); const access_token = url.searchParams.get('access_token'); - const isEditMode = url.searchParams.get('_edit') === 'true'; // Only when in edit mode - + const isEditMode = url.searchParams.get('_edit') === 'true'; if (access_token) { this.token = access_token; this._setTokenCookie(access_token); @@ -39,6 +42,8 @@ class Bridge { if (isEditMode) { this.enableBlockClickListener(); + this.injectCSS(); + this.setupMessageListener(); } } } @@ -77,29 +82,266 @@ class Bridge { 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, - ); + this.selectBlock(blockElement); } }; - // Ensure we don't add multiple listeners document.removeEventListener('click', this.blockClickHandler); document.addEventListener('click', this.blockClickHandler); } + selectBlock(blockElement) { + // Remove border and button from the previously selected block + if (this.currentlySelectedBlock) { + this.currentlySelectedBlock.classList.remove('volto-hydra--outline'); + if (this.addButton) { + this.addButton.remove(); + this.addButton = null; + } + if (this.deleteButton) { + this.deleteButton.remove(); + this.deleteButton = null; + } + if (this.quantaToolbar) { + this.quantaToolbar.remove(); + this.quantaToolbar = null; + } + } + + // 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'); + + // Create and append the Add button + this.addButton = document.createElement('button'); + this.addButton.className = 'volto-hydra-add-button'; + this.addButton.innerHTML = addSVG; + this.addButton.onclick = () => { + this.clickOnBtn = true; + window.parent.postMessage( + { type: 'ADD_BLOCK', uid: blockUid }, + this.adminOrigin, + ); + }; + this.currentlySelectedBlock.appendChild(this.addButton); + + // Create the quantaToolbar + this.quantaToolbar = document.createElement('div'); + this.quantaToolbar.className = 'volto-hydra-quantaToolbar'; + + // Prevent event propagation for the quantaToolbar + this.quantaToolbar.addEventListener('click', (e) => e.stopPropagation()); + + // Create the drag button + const dragButton = document.createElement('button'); + dragButton.className = 'volto-hydra-drag-button'; + dragButton.innerHTML = dragSVG; // Use your drag SVG here + dragButton.disabled = true; // Disable drag button for now + + // Create the three-dot menu button + const menuButton = document.createElement('button'); + menuButton.className = 'volto-hydra-menu-button'; + menuButton.innerHTML = threeDotsSVG; // Use your three dots SVG here + + // Create the dropdown menu + const dropdownMenu = document.createElement('div'); + dropdownMenu.className = 'volto-hydra-dropdown-menu'; + + // Create the 'Remove' option + const removeOption = document.createElement('div'); + removeOption.className = 'volto-hydra-dropdown-item'; + removeOption.innerHTML = `${deleteSVG}
Remove
`; + removeOption.onclick = () => { + this.clickOnBtn = true; + window.parent.postMessage( + { type: 'DELETE_BLOCK', uid: blockUid }, + this.adminOrigin, + ); + }; + + // Create the 'Settings' option + const settingsOption = document.createElement('div'); + settingsOption.className = 'volto-hydra-dropdown-item'; + settingsOption.innerHTML = `${settingsSVG}
Settings
`; + // ---Add settings click handler here (currently does nothing)--- + + // Create the divider + const divider = document.createElement('div'); + divider.className = 'volto-hydra-divider'; + + // Append options to the dropdown menu + dropdownMenu.appendChild(settingsOption); + dropdownMenu.appendChild(divider); + dropdownMenu.appendChild(removeOption); + + // Add event listener to toggle dropdown visibility + menuButton.addEventListener('click', () => { + dropdownMenu.classList.toggle('visible'); + }); + + // Append elements to the quantaToolbar + this.quantaToolbar.appendChild(dragButton); + this.quantaToolbar.appendChild(menuButton); + this.quantaToolbar.appendChild(dropdownMenu); + + // Append the quantaToolbar to the currently selected block + this.currentlySelectedBlock.appendChild(this.quantaToolbar); + + if (!this.clickOnBtn) { + window.parent.postMessage( + { type: 'OPEN_SETTINGS', uid: blockUid }, + this.adminOrigin, + ); + } else { + this.clickOnBtn = false; + } + } + + setupMessageListener() { + this.messageHandler = (event) => { + if ( + event.origin === this.adminOrigin && + event.data.type === 'SELECT_BLOCK' + ) { + const { uid } = event.data; + this.observeForBlock(uid); + } + }; + + window.removeEventListener('message', this.messageHandler); + window.addEventListener('message', this.messageHandler); + } + + observeForBlock(uid) { + const observer = new MutationObserver((mutationsList, observer) => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + const blockElement = document.querySelector( + `[data-block-uid="${uid}"]`, + ); + if (blockElement) { + this.selectBlock(blockElement); + observer.disconnect(); + break; + } + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } + + injectCSS() { + const style = document.createElement('style'); + style.type = 'text/css'; + style.innerHTML = ` + .volto-hydra--outline { + position: relative !important; + } + .volto-hydra--outline:before { + content: ""; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + border: 2px solid #2597F4; + border-radius: 6px; + pointer-events: none; + z-index: 5; + } + .volto-hydra-add-button { + position: absolute; + background: none; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 8px; + border-radius: 36px; + background-color: var(--gray-snow, #f3f5f7); + border: 2px solid rgb(37 151 244 / 50%); + } + .volto-hydra-add-button { + bottom: -49px; + right: 0; + transform: translateX(-50%); + } + .volto-hydra-quantaToolbar { + display: flex; + align-items: center; + position: absolute; + background: white; + box-shadow: 3px 3px 10px rgb(0 0 0 / 53%); + border-radius: 6px; + z-index: 10; + top: -45px; + left: 0; + box-sizing: border-box; + width: 70px; + } + .volto-hydra-drag-button, + .volto-hydra-menu-button { + background: none; + border: none; + cursor: pointer; + padding: 0.5em; + margin: 0; + } + .volto-hydra-drag-button { + cursor: default; + background: #E4E8EC; + border-radius: 6px; + padding: 9px 6px; + } + .volto-hydra-dropdown-menu { + display: none; + position: absolute; + top: 100%; + right: -200%; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 100; + margin-top: -8px; + width: 180px; + box-sizing: border-box; + } + .volto-hydra-dropdown-menu.visible { + display: block; + } + .volto-hydra-dropdown-item { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 10px; + cursor: pointer; + transition: background 0.2s; + } + .volto-hydra-dropdown-item svg { + margin-right: 1em; + } + .volto-hydra-dropdown-item:hover { + background: #f0f0f0; + } + .volto-hydra-divider { + height: 1px; + background: rgba(0, 0, 0, 0.1); + margin: 0 1em; + } + `; + document.head.appendChild(style); + } + // Method to clean up all event listeners cleanup() { if (this.navigationHandler) { @@ -111,6 +353,9 @@ class Bridge { if (this.blockClickHandler) { document.removeEventListener('click', this.blockClickHandler); } + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } } } @@ -152,7 +397,7 @@ export function getTokenFromCookie() { /** * Enable the frontend to listen for changes in the admin and call the callback with updated data - * @param {*} callback + * @param {*} callback - this will be called with the updated data */ export function onEditChange(callback) { if (bridgeInstance) { @@ -164,3 +409,22 @@ export function onEditChange(callback) { if (typeof window !== 'undefined') { window.initBridge = initBridge; } + +const deleteSVG = ` + +`; +const dragSVG = ` + + + +`; +const addSVG = ``; +const threeDotsSVG = ` + + + +`; +const settingsSVG = ` + + +`; diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index 0056f59..a9fa304 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -1,11 +1,22 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useHistory } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; import Cookies from 'js-cookie'; -import isValidUrl from '../../utils/isValidUrl'; +import { + applyBlockDefaults, + deleteBlock, + getBlocksFieldname, + insertBlock, + mutateBlock, + previousBlockId, +} from '@plone/volto/helpers'; import './styles.css'; -import { setSelectedBlock } from '../../actions'; -import usePresetUrls from '../../utils/usePresetsUrls'; +import { useIntl } from 'react-intl'; +import config from '@plone/volto/registry'; +import usePresetUrls from '../../utils/usePreseturls'; +import isValidUrl from '../../utils/isValidUrl'; +import { BlockChooser } from '@plone/volto/components'; +import { createPortal } from 'react-dom'; +import { usePopper } from 'react-popper'; import UrlInput from '../UrlInput'; /** @@ -16,26 +27,98 @@ import UrlInput from '../UrlInput'; */ const getUrlWithAdminParams = (url, token) => { return typeof window !== 'undefined' - ? `${url}${window.location.pathname.replace('/edit', '')}?access_token=${token}&_edit=true` + ? window.location.pathname.endsWith('/edit') + ? `${url}${window.location.pathname.replace('/edit', '')}?access_token=${token}&_edit=true` + : `${url}${window.location.pathname}?access_token=${token}&_edit=false` : null; }; -const Iframe = () => { - const dispatch = useDispatch(); - const [url, setUrl] = useState(''); +const Iframe = (props) => { + // ----Experimental---- + const { + onSelectBlock, + properties, + onChangeFormData, + metadata, + formData: form, + token, + allowedBlocks, + showRestricted, + blocksConfig = config.blocks.blocksConfig, + navRoot, + type: contentType, + selectedBlock, + } = props; + // const [ready, setReady] = useState(false); + // useEffect(() => { + // setReady(true); + // }, []); + const [addNewBlockOpened, setAddNewBlockOpened] = useState(false); + const [popperElement, setPopperElement] = useState(null); + const [referenceElement, setReferenceElement] = useState(null); + const blockChooserRef = useRef(); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + strategy: 'fixed', + placement: 'bottom', + modifiers: [ + { + name: 'offset', + options: { + offset: [0, -250], + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: ['right-end', 'top-start'], + }, + }, + ], + }); + //------------------------- + const [url, setUrl] = useState(''); const [src, setSrc] = useState(''); const history = useHistory(); - const token = useSelector((state) => state.userSession.token); - const form = useSelector((state) => state.form.global); - const presetUrls = usePresetUrls(); - const defaultUrl = presetUrls[0] || 'http://localhost:3002'; + const presetUrls = usePresetUrls(); + const defaultUrl = presetUrls[0]; const savedUrl = Cookies.get('iframe_url'); const initialUrl = savedUrl ? getUrlWithAdminParams(savedUrl, token) : getUrlWithAdminParams(defaultUrl, token); + //-----Experimental----- + const intl = useIntl(); + + const onInsertBlock = (id, value, current) => { + const [newId, newFormData] = insertBlock( + properties, + id, + value, + current, + config.experimental.addBlockButton.enabled ? 1 : 0, + ); + + const blocksFieldname = getBlocksFieldname(newFormData); + const blockData = newFormData[blocksFieldname][newId]; + newFormData[blocksFieldname][newId] = applyBlockDefaults({ + data: blockData, + intl, + metadata, + properties, + }); + + onChangeFormData(newFormData); + return newId; + }; + + const onMutateBlock = (id, value) => { + const newFormData = mutateBlock(properties, id, value); + onChangeFormData(newFormData); + }; + //--------------------------- + const handleNavigateToUrl = useCallback( (givenUrl = null) => { if (!isValidUrl(givenUrl) && !isValidUrl(url)) { @@ -46,17 +129,7 @@ const Iframe = () => { const newOrigin = formattedUrl.origin; Cookies.set('iframe_url', newOrigin, { expires: 7 }); - if (formattedUrl.pathname !== '/') { - history.push( - window.location.pathname.endsWith('/edit') - ? `${formattedUrl.pathname}/edit` - : `${formattedUrl.pathname}`, - ); - } else { - history.push( - window.location.pathname.endsWith('/edit') ? `/edit` : `/`, - ); - } + history.push(`${formattedUrl.pathname}`); }, [history, url], ); @@ -69,7 +142,23 @@ const Iframe = () => { }, [savedUrl, defaultUrl, initialUrl]); useEffect(() => { - const initialUrlOrigin = new URL(initialUrl).origin; + //----------------Experimental---------------- + const onDeleteBlock = (id, selectPrev) => { + const previous = previousBlockId(properties, id); + const newFormData = deleteBlock(properties, id); + onChangeFormData(newFormData); + + onSelectBlock(selectPrev ? previous : null); + const origin = new URL(src).origin; + document + .getElementById('previewIframe') + .contentWindow.postMessage( + { type: 'SELECT_BLOCK', uid: previous }, + origin, + ); + }; + //---------------------------------------------- + const initialUrlOrigin = initialUrl ? new URL(initialUrl).origin : ''; const messageHandler = (event) => { if (event.origin !== initialUrlOrigin) { return; @@ -82,10 +171,20 @@ const Iframe = () => { case 'OPEN_SETTINGS': if (history.location.pathname.endsWith('/edit')) { - dispatch(setSelectedBlock(event.data.uid)); + onSelectBlock(event.data.uid); + setAddNewBlockOpened(false); } break; + case 'ADD_BLOCK': + //----Experimental---- + setAddNewBlockOpened(true); + break; + + case 'DELETE_BLOCK': + onDeleteBlock(event.data.uid, true); + break; + default: break; } @@ -99,29 +198,83 @@ const Iframe = () => { window.removeEventListener('message', messageHandler); }; }, [ - dispatch, handleNavigateToUrl, history.location.pathname, initialUrl, + onChangeFormData, + onSelectBlock, + properties, + src, token, ]); useEffect(() => { - if (Object.keys(form).length > 0 && isValidUrl(initialUrl)) { + if (form && Object.keys(form).length > 0 && isValidUrl(src)) { // Send the form data to the iframe - const origin = new URL(initialUrl).origin; + const origin = new URL(src).origin; document .getElementById('previewIframe') .contentWindow.postMessage({ type: 'FORM', data: form }, origin); } - }, [form, initialUrl]); + }, [form, initialUrl, src]); return (
-