diff --git a/assets/src/blocks/Submenu/BackTop.js b/assets/src/blocks/Submenu/BackTop.js new file mode 100644 index 0000000000..188884baf7 --- /dev/null +++ b/assets/src/blocks/Submenu/BackTop.js @@ -0,0 +1,18 @@ +import useScrollPosition from '@react-hook/window-scroll'; + +export const BackTop = ({}) => { + const cookies = document.getElementById('set-cookie'); + + const cookiesVisible = cookies && cookies.style.display !== 'none'; + + const scrollPosition = useScrollPosition(60); + + return scrollPosition < 400 ? '' : window.scrollTo(0, 0) } + />; +}; diff --git a/assets/src/blocks/Submenu/SubmenuEditor.js b/assets/src/blocks/Submenu/SubmenuEditor.js index 6a204802c8..4c5cfac42f 100644 --- a/assets/src/blocks/Submenu/SubmenuEditor.js +++ b/assets/src/blocks/Submenu/SubmenuEditor.js @@ -3,8 +3,10 @@ import { Button, PanelBody } from '@wordpress/components'; import { SubmenuLevel } from './SubmenuLevel'; import { SubmenuItems } from './SubmenuItems'; import { InspectorControls } from '@wordpress/block-editor'; -import { getSubmenuStyle } from './submenuFunctions'; -import { useSubmenuItemsLoad } from './useSubmenuItemsLoad'; +import { getSubmenuStyle } from './getSubmenuStyle'; +import { makeHierarchical } from './getSubmenuItems'; +import { generateAnchor } from './generateAnchor'; +import { useSelect } from '@wordpress/data'; const { __ } = wp.i18n; const { RichText } = wp.blockEditor; @@ -69,11 +71,64 @@ const renderEdit = (attributes, setAttributes) => { ); } -const renderView = (attributes, setAttributes) => { +// We can put the other blocks that can have a header inside in here along with the attribute containing the heading text. +// Then we can also filter those to include them in the menu. +const blockTypesWithHeadings = [ + {name: 'planet4-blocks/articles', fieldName: 'article_heading', level: 2}, +]; - const { menuItems } = useSubmenuItemsLoad(attributes.levels, true); - const style = getSubmenuStyle(attributes.className, attributes.submenu_style); +const renderView = (attributes, setAttributes, className) => { + + const { blocks } = useSelect(select => { + return ({ blocks: select('core/editor').getBlocks() }); + }); + + const { levels: selectedLevels } = attributes + + const flatHeaders = []; + blocks.forEach(block => { + if (block.name === 'core/heading') { + const blockLevel = block.attributes.level + + const levelConfig = selectedLevels.find(selected => selected.heading === blockLevel); + + if (!levelConfig) { + return; + } + + const anchor = block.attributes.anchor || generateAnchor(block.attributes.content); + + flatHeaders.push({ + level: blockLevel, + content: block.attributes.content, + anchor, + style: levelConfig.style, + shouldLink: levelConfig.link, + }); + + return; + } + + const blockType = blockTypesWithHeadings.find(({ name }) => name === block.name); + + if (blockType) { + const { fieldName, level } = blockType; + const levelConfig = selectedLevels.find(selected => selected.heading === level); + + if (!levelConfig) { + return; + } + flatHeaders.push({ + level, + content: block.attributes[fieldName], + }); + } + }); + + const menuItems = makeHierarchical(flatHeaders); + + const style = getSubmenuStyle(className, attributes.submenu_style); return (
@@ -87,7 +142,7 @@ const renderView = (attributes, setAttributes) => { characterLimit={60} multiline="false" /> - {menuItems.length > 0 ? + {flatHeaders.length > 0 ? :
{__('The submenu block produces no output on the editor.', 'planet4-blocks-backend')} @@ -100,6 +155,6 @@ const renderView = (attributes, setAttributes) => { export const SubmenuEditor = ({ attributes, setAttributes, isSelected, className }) => ( {isSelected && renderEdit(attributes, setAttributes)} - {renderView({ className, ...attributes }, setAttributes)} + {renderView(attributes, setAttributes, className)} ); diff --git a/assets/src/blocks/Submenu/SubmenuFrontend.js b/assets/src/blocks/Submenu/SubmenuFrontend.js index 8b56e6b929..64d03caeee 100644 --- a/assets/src/blocks/Submenu/SubmenuFrontend.js +++ b/assets/src/blocks/Submenu/SubmenuFrontend.js @@ -1,20 +1,20 @@ -import { Fragment, useEffect } from '@wordpress/element'; -import { getSubmenuStyle, addSubmenuActions } from './submenuFunctions'; +import { getSubmenuStyle } from './getSubmenuStyle'; import { SubmenuItems } from './SubmenuItems'; -import { useSubmenuItemsLoad } from './useSubmenuItemsLoad'; +import { makeHierarchical } from './makeHierarchical'; +import { getHeadingsFromDom } from './getHeadingsFromDom'; +import { BackTop } from './BackTop'; export const SubmenuFrontend = ({ title, className, levels, submenu_style }) => { - const { menuItems } = useSubmenuItemsLoad(levels, false); - - useEffect(() => addSubmenuActions(menuItems), [menuItems]); - + const headings = getHeadingsFromDom(levels); + const menuItems = makeHierarchical(headings); const style = getSubmenuStyle(className, submenu_style); return ( -
-

{title}

- +
+

{ title }

+ +
); -} +}; diff --git a/assets/src/blocks/Submenu/SubmenuItems.js b/assets/src/blocks/Submenu/SubmenuItems.js index e375ba08ac..a2296d1531 100644 --- a/assets/src/blocks/Submenu/SubmenuItems.js +++ b/assets/src/blocks/Submenu/SubmenuItems.js @@ -1,29 +1,12 @@ export const SubmenuItems = ({ menuItems }) => { - const onSubmenuLinkClick = id => { - const target = document.getElementById(id); - if (target) { - $('html, body').animate({ - scrollTop: target.offsetTop - 100 - }, 2000, () => { - const position = window.pageYOffset; - window.location.hash = id; - window.scrollTo(0, position); - }); - } - } - const renderMenuItems = (items) => { - return items.map(({ text, style, link, id, children }) => ( -
  • - {link ? + return items.map(({ anchor, text, style, shouldLink, children }) => ( +
  • + {shouldLink ? { - event.preventDefault(); - onSubmenuLinkClick(id); - }} + href={`#${anchor}`} > {text} diff --git a/assets/src/blocks/Submenu/generateAnchor.js b/assets/src/blocks/Submenu/generateAnchor.js new file mode 100644 index 0000000000..8d6d0957df --- /dev/null +++ b/assets/src/blocks/Submenu/generateAnchor.js @@ -0,0 +1,2 @@ +export const generateAnchor = text => text.toLowerCase().trim().replace(/ /g, '-') + diff --git a/assets/src/blocks/Submenu/getHeadingsFromDom.js b/assets/src/blocks/Submenu/getHeadingsFromDom.js new file mode 100644 index 0000000000..996a2f30d2 --- /dev/null +++ b/assets/src/blocks/Submenu/getHeadingsFromDom.js @@ -0,0 +1,26 @@ +import { generateAnchor } from './generateAnchor'; + +const getHeadingLevel = heading => Number(heading.tagName.replace('H', '')); + +export const getHeadingsFromDom = (selectedLevels) => { + const container = document.querySelector('.page-template'); + // Get all heading tags that we need to query + const headingsSelector = selectedLevels.map(level => `:not(.submenu-block) h${level.heading}`); + + return [...container.querySelectorAll(headingsSelector)].map(heading=> { + const levelConfig = selectedLevels.find((selected) => selected.heading === getHeadingLevel(heading)) + + if (!heading.id) { + heading.id = generateAnchor(heading.textContent); + } + + return ({ + content: heading.textContent, + level: levelConfig.heading, + style: levelConfig.style, + shouldLink: levelConfig.link, + anchor: heading.id, + }); + }); +} + diff --git a/assets/src/blocks/Submenu/getSubmenuStyle.js b/assets/src/blocks/Submenu/getSubmenuStyle.js new file mode 100644 index 0000000000..08d844481c --- /dev/null +++ b/assets/src/blocks/Submenu/getSubmenuStyle.js @@ -0,0 +1,13 @@ +// Map for old attribute 'submenu_style' +const SUBMENU_STYLES = { + 1: 'long', + 2: 'short', + 3: 'sidebar' +}; + +export const getSubmenuStyle = (className, submenu_style) => { + if (className && className.includes('is-style-')) { + return className.split('is-style-')[1]; + } + return submenu_style ? SUBMENU_STYLES[submenu_style] : 'long'; +}; diff --git a/assets/src/blocks/Submenu/makeHierarchical.js b/assets/src/blocks/Submenu/makeHierarchical.js new file mode 100644 index 0000000000..599f9049f7 --- /dev/null +++ b/assets/src/blocks/Submenu/makeHierarchical.js @@ -0,0 +1,33 @@ +export const makeHierarchical = headings => { + let previousMenuItem; + + return headings.reduce((menuItems, heading) => { + const { level, shouldLink, anchor, content, style } = heading; + + // const parent = deeperThanPrevious ? previousHeading.children : menuItems; + let possibleParent = previousMenuItem || menuItems; + + while (possibleParent.level && possibleParent.level >= level) { + possibleParent = possibleParent.parent; + } + + const parent = possibleParent; + + const container = parent === menuItems ? menuItems : parent.children; + + const menuItem = { + text: content, + style: style, + children: [], + parent: parent, + level, + shouldLink, + anchor, + }; + container.push(menuItem); + + previousMenuItem = menuItem; + + return menuItems; + }, []); +}; diff --git a/assets/src/blocks/Submenu/submenuFunctions.js b/assets/src/blocks/Submenu/submenuFunctions.js deleted file mode 100644 index 51a3a9dc57..0000000000 --- a/assets/src/blocks/Submenu/submenuFunctions.js +++ /dev/null @@ -1,98 +0,0 @@ -// Map for old attribute 'submenu_style' -const SUBMENU_STYLES = { - 1: 'long', - 2: 'short', - 3: 'sidebar' -}; - -export const getSubmenuStyle = (className, submenu_style) => { - if (className && className.includes('is-style-')) { - return className.split('is-style-')[1]; - } - return submenu_style ? SUBMENU_STYLES[submenu_style] : 'long'; -}; - -export const addSubmenuActions = submenu => { - if (submenu && Array.isArray(submenu)) { - for (let i = 0; i < submenu.length; i++) { - const menu = submenu[i]; - addTargetIds(menu); - formatChildren(menu); - } - - // Add "back to top" button behaviour - let backtop = document.querySelector('a.back-top'); - const submenuBlock = document.querySelector('section.submenu-block'); - - if (submenuBlock) { - // If back to top button doesn't exist yet, we need to create it - if (!backtop) { - backtop = document.createElement('a'); - backtop.href = '#'; - backtop.className = 'back-top'; - document.body.appendChild(backtop); - } - addBackToTopBehaviour(backtop); - } - } -}; - -// Add onscroll function and proper positioning for back to top behaviour -const addBackToTopBehaviour = backtop => { - const cookies = document.getElementById('set-cookie'); - window.onscroll = () => { - if (window.pageYOffset > 400 && backtop.style.display !== 'block') { - backtop.style.display = 'block'; - if (cookies) { - const cookiesStyles = window.getComputedStyle(cookies); - if (cookiesStyles && cookiesStyles.display !== 'none') { - backtop.style.bottom = '120px'; - } else { - backtop.style.bottom = '50px'; - } - } - } else if (window.pageYOffset <= 400 && backtop.style.display !== 'none') { - backtop.style.display = 'none'; - } - }; -}; - -/** - * Format submenu entry children. - * - * @param menu Submenu entry - */ -const formatChildren = menu => { - if (menu.children && Array.isArray(menu.children)) { - for (let k = 0; k < menu.children.length; k++) { - const child = menu.children[k]; - addTargetIds(child); - formatChildren(child); - } - } -}; - -/** - * Add ids to the items, to be able to scroll to them. - * - * @param item Submenu menu item - */ -const addTargetIds = item => { - if (item.link) { - const headings = getHeadings(item.type); - if (headings) { - headings.forEach(heading => { - if (heading.textContent === item.text && !heading.id) { - heading.id = item.id; - } - }); - } - } -}; - -export const getHeadings = (headings, className = 'page-template') => { - // We need to make sure it's a div element, - // since for 'page-template' className we have it on the body too - const page = document.querySelector(`div.${className}`); - return page ? [...page.querySelectorAll(headings)] : null; -}; diff --git a/assets/src/blocks/Submenu/useSubmenuItemsLoad.js b/assets/src/blocks/Submenu/useSubmenuItemsLoad.js deleted file mode 100644 index 31b68fe136..0000000000 --- a/assets/src/blocks/Submenu/useSubmenuItemsLoad.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useState, useEffect } from '@wordpress/element'; -import { getHeadings } from './submenuFunctions'; - -export const useSubmenuItemsLoad = (levels, isEditing) => { - - const [menuItems, setMenuItems] = useState([]); - - const getHeadingNumber = tag => Number(tag.tagName.replace('H', '')); - - const loadMenuItems = () => { - // Get all heading tags that we need to query - const headings = levels.map(level => `h${level.heading}`); - const tags = getHeadings(headings, isEditing ? 'editor-styles-wrapper' : 'page-template'); - if (!tags) { - return []; - } - return tags.reduce((menuItems, tag, index) => { - const headingNumber = getHeadingNumber(tag); - let previousHeadingNumber = 0; - if (index > 0) { - previousHeadingNumber = getHeadingNumber(tags[index - 1]); - } - // Get the properties that we need to create the new menu item - const correspondingLevel = levels.find(level => level.heading === headingNumber); - const id = tag.id || tag.textContent.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, ''); // equivalent of WP sanitize_title function - const menuItem = { - text: tag.textContent, - id: `${id}-h${headingNumber}-${index}`, - style: correspondingLevel.style, - link: correspondingLevel.link, - type: `h${headingNumber}`, - children: [] - }; - if (previousHeadingNumber && headingNumber > previousHeadingNumber) { - // In this case we need to add this menuItem to the children of the previous one - menuItems[menuItems.length - 1].children.push(menuItem); - } else { - menuItems.push(menuItem); - } - return menuItems; - }, []); - }; - - useEffect(() => { - const items = loadMenuItems(); - setMenuItems(items); - }, [levels]); - - return { menuItems }; -}; diff --git a/package-lock.json b/package-lock.json index 714d0af32b..bbdd8cfa6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1606,6 +1606,42 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@react-hook/event": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@react-hook/event/-/event-1.2.2.tgz", + "integrity": "sha512-TDPC2zyMnvExp6Brg5pbEt8yFGoiVFcyduW2auqbJ825pmlK91rrC+/k8UZpnPjA6UdmFRX16uY9PYeydpJW1Q==", + "requires": { + "@react-hook/latest": "^1.0.2", + "@react-hook/passive-layout-effect": "^1.2.0" + } + }, + "@react-hook/latest": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.2.tgz", + "integrity": "sha512-zLtOIToct1EBTbwldkMJsXC2eCsmWOOP7z6UG0M/sCgnPExtIjvVMCpPESvPnMbQzDZytXVy0nvMbUuK2gZs2A==" + }, + "@react-hook/passive-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.0.tgz", + "integrity": "sha512-obGcMHoeiG/qp9djzlxwvlsAzL4a+JFaxTLvJv8GykipmS/TEkfvdcpu6orE1ZL4dAT7OpoGwHDc/LGJbkZzXQ==" + }, + "@react-hook/throttle": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-hook/throttle/-/throttle-2.2.0.tgz", + "integrity": "sha512-LJ5eg+yMV8lXtqK3lR+OtOZ2WH/EfWvuiEEu0M3bhR7dZRfTyEJKxH1oK9uyBxiXPtWXiQggWbZirMCXam51tg==", + "requires": { + "@react-hook/latest": "^1.0.2" + } + }, + "@react-hook/window-scroll": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@react-hook/window-scroll/-/window-scroll-1.3.0.tgz", + "integrity": "sha512-LdYnCL22pFI+LTs85Fi2OQHSKWkzIuHFgv8lA+wwuaPxLOEhWR5bzJ21iygUH9X4meeLVRZKEbfpYi3OWWD4GQ==", + "requires": { + "@react-hook/event": "^1.2.1", + "@react-hook/throttle": "^2.2.0" + } + }, "@sinonjs/commons": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.0.tgz", diff --git a/package.json b/package.json index 57961c0043..041c6f06f9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "webpack-fix-style-only-entries": "^0.3.1" }, "dependencies": { + "@react-hook/window-scroll": "^1.3.0", "classnames": "^2.2.6", "jest-environment-node": "^26.0.1" }