From 0885a140d01c881f4bcdf19cf6f19a62f0096f38 Mon Sep 17 00:00:00 2001
From: Osong Agberndifor <38656549+Osong-Michael@users.noreply.github.com>
Date: Wed, 17 Jan 2024 13:15:57 +0100
Subject: [PATCH] PLANET-7382 Move Submenu block into master theme (#2195)
- Moved the Submenu block to master theme with associated funcctions
---
assets/src/blocks/Submenu/SubmenuBlock.js | 71 ++++++++++
assets/src/blocks/Submenu/SubmenuEditor.js | 126 ++++++++++++++++++
assets/src/blocks/Submenu/SubmenuFrontend.js | 19 +++
assets/src/blocks/Submenu/SubmenuItems.js | 30 +++++
assets/src/blocks/Submenu/SubmenuLevel.js | 61 +++++++++
assets/src/blocks/Submenu/example.js | 118 ++++++++++++++++
assets/src/blocks/Submenu/generateAnchor.js | 12 ++
.../blocks/Submenu/getHeadingsFromBlocks.js | 84 ++++++++++++
.../src/blocks/Submenu/getHeadingsFromDom.js | 33 +++++
assets/src/blocks/Submenu/getSubmenuStyle.js | 17 +++
assets/src/blocks/Submenu/makeHierarchical.js | 33 +++++
assets/src/blocks/editorIndex.js | 4 +
assets/src/blocks/frontendIndex.js | 7 +-
assets/src/functions/deepClone.js | 61 +++++++++
assets/src/functions/unescape.js | 6 +
assets/src/scss/blocks.scss | 1 +
.../blocks/Submenu/SubmenuEditorStyle.scss | 34 +++++
.../src/scss/blocks/Submenu/SubmenuStyle.scss | 119 +++++++++++++++++
assets/src/scss/editorStyle.scss | 1 +
src/Blocks/Submenu.php | 107 +++++++++++++++
src/Loader.php | 1 +
21 files changed, 944 insertions(+), 1 deletion(-)
create mode 100644 assets/src/blocks/Submenu/SubmenuBlock.js
create mode 100644 assets/src/blocks/Submenu/SubmenuEditor.js
create mode 100644 assets/src/blocks/Submenu/SubmenuFrontend.js
create mode 100644 assets/src/blocks/Submenu/SubmenuItems.js
create mode 100644 assets/src/blocks/Submenu/SubmenuLevel.js
create mode 100644 assets/src/blocks/Submenu/example.js
create mode 100644 assets/src/blocks/Submenu/generateAnchor.js
create mode 100644 assets/src/blocks/Submenu/getHeadingsFromBlocks.js
create mode 100644 assets/src/blocks/Submenu/getHeadingsFromDom.js
create mode 100644 assets/src/blocks/Submenu/getSubmenuStyle.js
create mode 100644 assets/src/blocks/Submenu/makeHierarchical.js
create mode 100644 assets/src/functions/deepClone.js
create mode 100644 assets/src/functions/unescape.js
create mode 100644 assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss
create mode 100644 assets/src/scss/blocks/Submenu/SubmenuStyle.scss
create mode 100644 src/Blocks/Submenu.php
diff --git a/assets/src/blocks/Submenu/SubmenuBlock.js b/assets/src/blocks/Submenu/SubmenuBlock.js
new file mode 100644
index 0000000000..89de6b4898
--- /dev/null
+++ b/assets/src/blocks/Submenu/SubmenuBlock.js
@@ -0,0 +1,71 @@
+import {SubmenuEditor} from './SubmenuEditor.js';
+import {example} from './example';
+import {getStyleLabel} from '../../functions/getStyleLabel';
+
+const {__} = wp.i18n;
+
+const BLOCK_NAME = 'planet4-blocks/submenu';
+
+export const registerSubmenuBlock = () => {
+ const {registerBlockType} = wp.blocks;
+
+ registerBlockType(BLOCK_NAME, {
+ title: 'Submenu',
+ icon: 'welcome-widgets-menus',
+ category: 'planet4-blocks',
+ attributes: {
+ title: {
+ type: 'string',
+ default: '',
+ },
+ submenu_style: { // Needed for old blocks conversion
+ type: 'integer',
+ default: 0,
+ },
+ levels: {
+ type: 'array',
+ default: [{heading: 2, link: false, style: 'none'}],
+ },
+ isExample: {
+ type: 'boolean',
+ default: false,
+ },
+ exampleMenuItems: { // Used for the block's preview, which can't extract items from anything.
+ type: 'array',
+ },
+ },
+ supports: {
+ multiple: false, // Use the block just once per post.
+ html: false,
+ },
+ styles: [
+ {
+ name: 'long',
+ label: getStyleLabel(
+ __('Long full-width', 'planet4-blocks-backend'),
+ __('Use: on long pages (more than 5 screens) when list items are long (+ 10 words). No max items recommended.', 'planet4-blocks-backend')
+ ),
+ isDefault: true,
+ },
+ {
+ name: 'short',
+ label: getStyleLabel(
+ __('Short full-width', 'planet4-blocks-backend'),
+ __('Use: on long pages (more than 5 screens) when list items are short (up to 5 words). No max items recommended.', 'planet4-blocks-backend')
+ ),
+ },
+ {
+ name: 'sidebar',
+ label: getStyleLabel(
+ __('Short sidebar', 'planet4-blocks-backend'),
+ __('Use: on long pages (more than 5 screens) when list items are short (up to 10 words). Max items recommended: 9', 'planet4-blocks-backend')
+ ),
+ },
+ ],
+ edit: SubmenuEditor,
+ save() {
+ return null;
+ },
+ example,
+ });
+};
diff --git a/assets/src/blocks/Submenu/SubmenuEditor.js b/assets/src/blocks/Submenu/SubmenuEditor.js
new file mode 100644
index 0000000000..76175a74b8
--- /dev/null
+++ b/assets/src/blocks/Submenu/SubmenuEditor.js
@@ -0,0 +1,126 @@
+import {Button, PanelBody} from '@wordpress/components';
+import {SubmenuLevel} from './SubmenuLevel';
+import {SubmenuItems} from './SubmenuItems';
+import {InspectorControls, RichText} from '@wordpress/block-editor';
+import {getSubmenuStyle} from './getSubmenuStyle';
+import {makeHierarchical} from './makeHierarchical';
+import {getHeadingsFromBlocks} from './getHeadingsFromBlocks';
+import {useSelect} from '@wordpress/data';
+import {deepClone} from '../../functions/deepClone';
+
+const {__} = wp.i18n;
+
+const renderEdit = (attributes, setAttributes) => {
+ function addLevel() {
+ const [previousLastLevel] = attributes.levels.slice(-1);
+ const newLevel = previousLastLevel.heading + 1;
+ setAttributes({levels: attributes.levels.concat({heading: newLevel, link: false, style: 'none'})});
+ }
+
+ function onHeadingChange(index, value) {
+ const levels = deepClone(attributes.levels);
+ levels[index].heading = Number(value);
+ setAttributes({levels});
+ }
+
+ function onLinkChange(index, value) {
+ const levels = deepClone(attributes.levels);
+ levels[index].link = value;
+ setAttributes({levels});
+ }
+
+ function onStyleChange(index, value) {
+ const levels = deepClone(attributes.levels);
+ levels[index].style = value; // Possible values: "none", "bullet", "number"
+ setAttributes({levels});
+ }
+
+ function removeLevel() {
+ setAttributes({levels: attributes.levels.slice(0, -1)});
+ }
+
+ function getMinLevel(attr, index) {
+ if (index === 0) {
+ return null;
+ }
+
+ return attr.levels[index - 1].heading;
+ }
+
+ return (
+
+
+ {attributes.levels.map((level, i) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const renderView = (attributes, setAttributes, className) => {
+ const {
+ title,
+ levels,
+ submenu_style,
+ isExample,
+ exampleMenuItems,
+ } = attributes;
+
+ const blocks = useSelect(select => select('core/block-editor').getBlocks(), null);
+
+ const flatHeadings = getHeadingsFromBlocks(blocks, levels);
+
+ const menuItems = isExample ? exampleMenuItems : makeHierarchical(flatHeadings);
+
+ const style = getSubmenuStyle(className, submenu_style);
+
+ return (
+
+ setAttributes({title: titl})}
+ withoutInteractiveFormatting
+ allowedFormats={[]}
+ />
+ {menuItems.length > 0 ?
+ :
+
+ {__('The submenu block produces no output on the editor.', 'planet4-blocks-backend')}
+
+ }
+
+ );
+};
+
+export const SubmenuEditor = ({attributes, setAttributes, isSelected, className}) => (
+ <>
+ {isSelected && renderEdit(attributes, setAttributes)}
+ {renderView(attributes, setAttributes, className)}
+ >
+);
diff --git a/assets/src/blocks/Submenu/SubmenuFrontend.js b/assets/src/blocks/Submenu/SubmenuFrontend.js
new file mode 100644
index 0000000000..2e7683bd19
--- /dev/null
+++ b/assets/src/blocks/Submenu/SubmenuFrontend.js
@@ -0,0 +1,19 @@
+import {getSubmenuStyle} from './getSubmenuStyle';
+import {SubmenuItems} from './SubmenuItems';
+import {makeHierarchical} from './makeHierarchical';
+import {getHeadingsFromDom} from './getHeadingsFromDom';
+
+export const SubmenuFrontend = ({title, className, levels, submenu_style}) => {
+ 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
new file mode 100644
index 0000000000..0b443ac684
--- /dev/null
+++ b/assets/src/blocks/Submenu/SubmenuItems.js
@@ -0,0 +1,30 @@
+export const SubmenuItems = ({menuItems}) => {
+ const renderMenuItems = items => {
+ return items.map(({anchor, text, style, shouldLink, children}) => (
+
+ {shouldLink ?
+
+ {text}
+ :
+ {text}
+ }
+ {children && children.length > 0 &&
+
+ {renderMenuItems(children)}
+
+ }
+
+ ));
+ };
+
+ return menuItems.length > 0 && (
+
+
+ {renderMenuItems(menuItems)}
+
+
+ );
+};
diff --git a/assets/src/blocks/Submenu/SubmenuLevel.js b/assets/src/blocks/Submenu/SubmenuLevel.js
new file mode 100644
index 0000000000..109219c005
--- /dev/null
+++ b/assets/src/blocks/Submenu/SubmenuLevel.js
@@ -0,0 +1,61 @@
+import {
+ CheckboxControl,
+ SelectControl,
+} from '@wordpress/components';
+
+const {__} = wp.i18n;
+
+const getHeadingOptions = minLevel => {
+ return [
+ {label: __('Heading 2', 'planet4-blocks-backend'), value: 2},
+ {label: __('Heading 3', 'planet4-blocks-backend'), value: 3},
+ {label: __('Heading 4', 'planet4-blocks-backend'), value: 4},
+ {label: __('Heading 5', 'planet4-blocks-backend'), value: 5},
+ {label: __('Heading 6', 'planet4-blocks-backend'), value: 6},
+ ].map(option => ({...option, disabled: option.value <= minLevel}));
+};
+
+export const SubmenuLevel = props => {
+ const {
+ index,
+ heading,
+ onLinkChange,
+ link,
+ onHeadingChange,
+ style,
+ onStyleChange,
+ minLevel,
+ } = props;
+
+ return (
+
+
{`${__('Level', 'planet4-blocks-backend')} ${Number(index + 1)}`}
+
onHeadingChange(index, e)}
+ />
+
+ onLinkChange(index, e)}
+ className="submenu-level-link"
+ />
+
+ onStyleChange(index, e)}
+ />
+
+
+ );
+};
diff --git a/assets/src/blocks/Submenu/example.js b/assets/src/blocks/Submenu/example.js
new file mode 100644
index 0000000000..7c8ba7d253
--- /dev/null
+++ b/assets/src/blocks/Submenu/example.js
@@ -0,0 +1,118 @@
+export const example = {
+ viewportWidth: 992,
+ attributes: {
+ isExample: true,
+ title: 'The block title',
+ exampleMenuItems: [
+ {
+ text: 'Title 1',
+ anchor: 'Title 1',
+ style: 'number',
+ children: [],
+ level: 2,
+ shouldLink: true,
+ },
+ {
+ text: 'Title 2',
+ anchor: 'Title 2',
+ style: 'number',
+ level: 2,
+ shouldLink: true,
+ children: [
+ {
+ text: 'Title 2.1',
+ anchor: 'Title 2.1',
+ style: 'number',
+ level: 3,
+ shouldLink: true,
+ children: [
+ {
+ text: 'Title 2.1.1',
+ anchor: 'Title 2.1.1',
+ style: 'bullet',
+ children: [],
+ level: 4,
+ shouldLink: false,
+ },
+ ],
+ },
+ {
+ text: 'Title 2.2',
+ anchor: 'Title 2.2',
+ style: 'number',
+ level: 3,
+ shouldLink: true,
+ children: [
+ {
+ text: 'Title 2.2.1',
+ anchor: 'Title 2.2.1',
+ style: 'bullet',
+ level: 4,
+ shouldLink: false,
+ children: [],
+ },
+ {
+ text: 'Title 2.2.2',
+ anchor: 'Title 2.2.2',
+ style: 'bullet',
+ level: 4,
+ shouldLink: false,
+ children: [],
+ },
+ ],
+ },
+ {
+ text: 'Title 2.3',
+ anchor: 'Title 2.3',
+ style: 'number',
+ level: 3,
+ shouldLink: true,
+ children: [],
+ },
+ {
+ text: 'Title 2.4',
+ anchor: 'Title 2.4',
+ style: 'number',
+ level: 3,
+ shouldLink: true,
+ children: [
+ {
+ text: 'Title 2.4.1',
+ anchor: 'Title 2.4.1',
+ style: 'bullet',
+ level: 4,
+ shouldLink: false,
+ children: [],
+ },
+ ],
+ },
+ {
+ text: 'Title 2.5',
+ anchor: 'Title 2.5',
+ style: 'number',
+ shouldLink: true,
+ children: [],
+ level: 3,
+ },
+ ],
+ },
+ {
+ text: 'Title 3',
+ anchor: 'Title 3',
+ style: 'number',
+ level: 2,
+ shouldLink: true,
+ children: [
+ {
+ text: 'Title 3.1',
+ anchor: 'Title 3.1',
+ style: 'number',
+ level: 3,
+ shouldLink: true,
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+};
diff --git a/assets/src/blocks/Submenu/generateAnchor.js b/assets/src/blocks/Submenu/generateAnchor.js
new file mode 100644
index 0000000000..f1641b8971
--- /dev/null
+++ b/assets/src/blocks/Submenu/generateAnchor.js
@@ -0,0 +1,12 @@
+export const generateAnchor = (text, previousAnchors) => {
+ const anchor = text.toLowerCase().trim().replace(/[^a-zA-Z\d:\u00C0-\u00FF]/g, '-');
+
+ let i = 0,
+ unique = anchor;
+
+ while (previousAnchors.includes(unique)) {
+ unique = `${anchor}-${++i}`;
+ }
+
+ return unique;
+};
diff --git a/assets/src/blocks/Submenu/getHeadingsFromBlocks.js b/assets/src/blocks/Submenu/getHeadingsFromBlocks.js
new file mode 100644
index 0000000000..71eba53769
--- /dev/null
+++ b/assets/src/blocks/Submenu/getHeadingsFromBlocks.js
@@ -0,0 +1,84 @@
+import {generateAnchor} from './generateAnchor';
+import {unescape} from '../../functions/unescape';
+
+// We can put the other blocks that can have a heading 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},
+];
+
+// Naive regex to remove html tags. Don't use anywhere else as it's too limited, but for the expected HTML in heading
+// blocks this should be sufficient.
+const stripTags = str => str.replace(/(<([^>]+)>)/ig, ''); //NOSONAR
+
+export const getHeadingsFromBlocks = (blocks, selectedLevels) => {
+ const headings = [];
+ 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, headings.map(h => h.anchor));
+
+ headings.push({
+ level: blockLevel,
+ // The content of RichText elements will always come out escaped. This is problematic as those will be displayed
+ // literally when we render them. It seems safe to unescape here as the value will not be used without escaping.
+ content: unescape(stripTags(block.attributes.content)),
+ anchor,
+ style: levelConfig.style,
+ shouldLink: levelConfig.link,
+ });
+
+ return;
+ }
+
+ if (block.name === 'core/freeform') {
+ const parser = new DOMParser();
+ const selector = selectedLevels.map(({heading}) => `h${heading}`).join();
+ const doc = parser.parseFromString(block.attributes.content, 'text/html');
+
+ const classicHeadings = doc.querySelectorAll(selector);
+
+ headings.push(...[...classicHeadings].map(h => {
+ const blockLevel = parseInt(h.tagName.replace('H', ''));
+ const levelConfig = selectedLevels.find(selected => selected.heading === blockLevel);
+
+ const anchor = h.id || generateAnchor(h.innerText, headings.map(hh => hh.anchor));
+
+ return ({
+ level: blockLevel,
+ content: h.innerText,
+ 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;
+ }
+ headings.push({
+ level,
+ content: block.attributes[fieldName],
+ });
+ }
+ });
+
+ return headings;
+};
+
diff --git a/assets/src/blocks/Submenu/getHeadingsFromDom.js b/assets/src/blocks/Submenu/getHeadingsFromDom.js
new file mode 100644
index 0000000000..906bd12f2f
--- /dev/null
+++ b/assets/src/blocks/Submenu/getHeadingsFromDom.js
@@ -0,0 +1,33 @@
+import {generateAnchor} from './generateAnchor';
+
+const getHeadingLevel = heading => Number(heading.tagName.replace('H', ''));
+
+export const getHeadingsFromDom = selectedLevels => {
+ const container = document.querySelector('.page-content');
+ if (!container || !selectedLevels) {
+ return [];
+ }
+
+ // Get all heading tags that we need to query
+ const headingsSelector = selectedLevels.map(level => `:not(.submenu-block) h${level.heading}`);
+
+ const usedAnchors = [];
+
+ return [...container.querySelectorAll(headingsSelector)].map(heading => {
+ const levelConfig = selectedLevels.find(selected => selected.heading === getHeadingLevel(heading));
+
+ if (!heading.id) {
+ heading.id = generateAnchor(heading.textContent, usedAnchors);
+ }
+
+ usedAnchors.push(heading.id);
+
+ 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..78a6634a75
--- /dev/null
+++ b/assets/src/blocks/Submenu/getSubmenuStyle.js
@@ -0,0 +1,17 @@
+import {getStyleFromClassName} from '../../functions/getStyleFromClassName';
+
+// Map for old attribute 'submenu_style'
+const SUBMENU_STYLES = {
+ 1: 'long',
+ 2: 'short',
+ 3: 'sidebar',
+};
+
+export const getSubmenuStyle = (className, submenu_style) => {
+ const styleClass = getStyleFromClassName(className);
+ if (styleClass) {
+ return styleClass;
+ }
+
+ 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..621ef40fae
--- /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,
+ children: [],
+ parent,
+ level,
+ shouldLink,
+ anchor,
+ };
+ container.push(menuItem);
+
+ previousMenuItem = menuItem;
+
+ return menuItems;
+ }, []);
+};
diff --git a/assets/src/blocks/editorIndex.js b/assets/src/blocks/editorIndex.js
index 2e845099da..7c78e77c2c 100644
--- a/assets/src/blocks/editorIndex.js
+++ b/assets/src/blocks/editorIndex.js
@@ -1,10 +1,14 @@
import {registerPostsListBlock} from './PostsList';
import {registerActionsList} from './ActionsList';
+import {registerSubmenuBlock} from './Submenu/SubmenuBlock';
wp.domReady(() => {
// Make sure to unregister the posts-list native variation before registering planet4-blocks/posts-list-block
wp.blocks.unregisterBlockVariation('core/query', 'posts-list');
+ // Blocks
+ registerSubmenuBlock();
+
// Beta blocks
registerActionsList();
registerPostsListBlock();
diff --git a/assets/src/blocks/frontendIndex.js b/assets/src/blocks/frontendIndex.js
index 96a79e4151..a1cb6f9c0e 100644
--- a/assets/src/blocks/frontendIndex.js
+++ b/assets/src/blocks/frontendIndex.js
@@ -1,5 +1,9 @@
+import {createRoot} from 'react-dom/client';
+import {SubmenuFrontend} from './Submenu/SubmenuFrontend';
+
// Render React components
const COMPONENTS = {
+ 'planet4-blocks/submenu': SubmenuFrontend,
};
document.addEventListener('DOMContentLoaded', () => {
@@ -15,7 +19,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const attributes = JSON.parse(blockNode.dataset.attributes);
- wp.element.render(, blockNode);
+ const rootElement = createRoot(blockNode);
+ rootElement.render();
}
);
});
diff --git a/assets/src/functions/deepClone.js b/assets/src/functions/deepClone.js
new file mode 100644
index 0000000000..7ef7cf7fd9
--- /dev/null
+++ b/assets/src/functions/deepClone.js
@@ -0,0 +1,61 @@
+// Following is needed to get the right global object in all environments.
+// See https://stackoverflow.com/a/6930376/4961158.
+let global;
+try {
+ global = Function('return this')();
+} catch (e) {
+ global = window;
+}
+
+// A deep clone that has the same result as doing JSON.parse(JSON.stringify(value)).
+// It's 2 to 5 times faster than both JSON switcheroo and lodash's cloneDeep in most cases.
+// Only for really large object sizes, e.g. 500KB and over it's roughly the same or a little slower than Lodash.
+// It does not preserve the object in the same way as Lodash does, i.e. non-built in classes are not preserved, just
+// like they aren't in the JSON switcheroo approach. So it's only intended to be used where you would otherwise be able
+// to use the JSON approach.
+const _deepClone = (value, ancestors = [], clones = []) => {
+ const type = typeof value;
+ if (type === 'function') {
+ return undefined;
+ }
+ if (type !== 'object') {
+ return value;
+ }
+ if (ancestors.includes(value)) {
+ return clones[ancestors.indexOf(value)];
+ }
+ if (Array.isArray(value)) {
+ const cloned = value.map(v => _deepClone(v, ancestors, clones));
+ ancestors.push(value);
+ clones.push(cloned);
+ // We actually do want to check the builtins here.
+ /* eslint-disable no-prototype-builtins */
+ if (value.hasOwnProperty('index')) {
+ cloned.index = value.index;
+ }
+ if (value.hasOwnProperty('input')) {
+ cloned.input = value.input;
+ }
+ /* eslint-enable no-prototype-builtins */
+
+ return cloned;
+ }
+
+ const valueOf = value.valueOf();
+
+ // If there is a valueOf and it's not an object, we need to pass it to the new constructor to get a new object with
+ // the same value. Needed for Date, also makes Boolean objects work (even though you shouldn't use them).
+ const param = typeof valueOf === 'object' ? null : valueOf;
+ // Don't try to construct custom objects, use Object instead, which behaves the same as the JSON approach.
+ const constructor = global[value.constructor.name] || Object;
+ const newObject = new constructor(param);
+
+ ancestors.push(value);
+ clones.push(newObject);
+
+ Object.keys(value).forEach(k => newObject[k] = _deepClone(value[k], ancestors, clones));
+
+ return newObject;
+};
+
+export const deepClone = value => _deepClone(value);
diff --git a/assets/src/functions/unescape.js b/assets/src/functions/unescape.js
new file mode 100644
index 0000000000..04eb000b8b
--- /dev/null
+++ b/assets/src/functions/unescape.js
@@ -0,0 +1,6 @@
+export const unescape = input => {
+ const e = document.createElement('textarea');
+ e.innerHTML = input;
+ // handle case of empty input
+ return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue;
+};
diff --git a/assets/src/scss/blocks.scss b/assets/src/scss/blocks.scss
index 0e41e1c882..ae678444f0 100644
--- a/assets/src/scss/blocks.scss
+++ b/assets/src/scss/blocks.scss
@@ -5,3 +5,4 @@
@import "blocks/Cookies/CookiesStyle";
@import "blocks/Counter/CounterStyle";
@import "blocks/Spreadsheet";
+@import "blocks/Submenu/SubmenuStyle";
diff --git a/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss b/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss
new file mode 100644
index 0000000000..e71473145a
--- /dev/null
+++ b/assets/src/scss/blocks/Submenu/SubmenuEditorStyle.scss
@@ -0,0 +1,34 @@
+.submenu-level-link label {
+ margin-bottom: 0;
+}
+
+[data-type="planet4-blocks/submenu"] {
+ clear: both;
+}
+
+.editor-styles-wrapper {
+ .submenu-block h2 {
+ padding-left: 16px;
+ color: var(--grey-800);
+ font-family: var(--font-family-heading);
+ }
+
+ // Needed to make it easily selectable in the editor
+ .submenu-sidebar {
+ float: none;
+ margin-right: 0;
+ margin-left: auto;
+
+ html[dir="rtl"] & {
+ float: none;
+ margin-right: auto;
+ margin-left: 0;
+ }
+ }
+}
+
+.EmptyMessage {
+ font-family: var(--font-family-primary);
+ padding: $sp-4;
+ background: var(--gp-green-200);
+}
diff --git a/assets/src/scss/blocks/Submenu/SubmenuStyle.scss b/assets/src/scss/blocks/Submenu/SubmenuStyle.scss
new file mode 100644
index 0000000000..425981cb29
--- /dev/null
+++ b/assets/src/scss/blocks/Submenu/SubmenuStyle.scss
@@ -0,0 +1,119 @@
+// On outer element as otherwise it will not be clickable when floating on the right.
+div[data-render="planet4-blocks/submenu"] {
+ z-index: 4;
+}
+
+.submenu-block {
+ _-- {
+ border-radius: 4px;
+ box-shadow: 0 3px 8px 0 rgba(28, 28, 28, 0.2);
+ background-color: var(--white);
+ padding: $sp-5 $sp-6 $sp-6 $sp-6;
+ }
+
+ h1,
+ h2 {
+ --submenu-block-heading-- {
+ font-family: var(--font-family-primary);
+ color: var(--grey-900);
+ padding-inline-start: 0;
+ }
+ }
+
+ .submenu-menu {
+ display: flex;
+ flex-wrap: wrap;
+
+ ul.submenu-item {
+ flex-basis: 100%;
+
+ @include large-and-up {
+ flex-basis: 50%;
+ }
+ }
+
+ a {
+ color: var(--color-text-body);
+ }
+
+ ul {
+ --submenu-block-menu-- {
+ font-family: var(--font-family-tertiary);
+ color: var(--color-text-body);
+ margin: 0;
+ padding: 0;
+ }
+ list-style: none;
+ }
+
+ li {
+ &.list-style-bullet {
+ --submenu-block-bullet-item-- {
+ margin-inline-end: 0;
+ margin-inline-start: $sp-4;
+ }
+ list-style: disc;
+ }
+
+ &.list-style-number {
+ --submenu-block-number-item-- {
+ margin-inline-start: $sp-4;
+ }
+ list-style: decimal;
+ }
+ }
+ }
+
+ &.submenu-short {
+ .submenu-menu {
+ @include medium-and-up {
+ ul.submenu-item {
+ flex-basis: 100%;
+ column-count: 3;
+
+ li:before {
+ content: "";
+ }
+ }
+ }
+ }
+ }
+
+ &.submenu-long {
+ .submenu-menu {
+ ul.submenu-item {
+ flex-basis: 100%;
+ column-count: 1;
+
+ @include medium-and-up {
+ column-count: 2;
+ }
+ }
+ }
+ }
+
+ &.submenu-sidebar {
+ z-index: 4;
+
+ @include medium-and-up {
+ float: right;
+ max-width: 350px;
+ margin-bottom: $sp-2;
+ margin-inline-start: $sp-2;
+
+ html[dir="rtl"] & {
+ float: left;
+ }
+ }
+
+ .submenu-menu {
+ ul.submenu-item {
+ flex-basis: 100%;
+
+ @include large-and-up {
+ flex-basis: 100%;
+ }
+ }
+ }
+ }
+}
diff --git a/assets/src/scss/editorStyle.scss b/assets/src/scss/editorStyle.scss
index 2f3e948e7e..3265d5e3c7 100644
--- a/assets/src/scss/editorStyle.scss
+++ b/assets/src/scss/editorStyle.scss
@@ -34,3 +34,4 @@
@import "blocks/CarouselHeader/CarouselHeaderEditorStyle";
@import "blocks/Accordion/AccordionEditorStyle";
@import "blocks/Cookies/CookiesEditorStyle";
+@import "blocks/Submenu/SubmenuEditorStyle.scss";
diff --git a/src/Blocks/Submenu.php b/src/Blocks/Submenu.php
new file mode 100644
index 0000000000..420151db87
--- /dev/null
+++ b/src/Blocks/Submenu.php
@@ -0,0 +1,107 @@
+register_submenu_block();
+ }
+
+ /**
+ * Register Submenu block.
+ */
+ public function register_submenu_block(): void
+ {
+ register_block_type(
+ self::get_full_block_name(),
+ [
+ // todo: Remove when all content is migrated.
+ 'render_callback' => [ self::class, 'render_frontend' ],
+ 'attributes' => [
+ 'title' => [
+ 'type' => 'string',
+ 'default' => '',
+ ],
+ 'submenu_style' => [ // Needed for old blocks conversion.
+ 'type' => 'integer',
+ 'default' => 0,
+ ],
+ /**
+ * Levels is an array of objects.
+ * Object structure:
+ * {
+ * heading: 'integer'
+ * link: 'boolean'
+ * style: 'string'
+ * }
+ */
+ 'levels' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ // In JSON Schema you can specify object properties in the properties attribute.
+ 'properties' => [
+ 'heading' => [
+ 'type' => 'integer',
+ ],
+ 'link' => [
+ 'type' => 'boolean',
+ ],
+ 'style' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ 'default' => [
+ [
+ 'heading' => 2,
+ 'link' => false,
+ 'style' => 'none',
+ ],
+ ],
+ ],
+ ],
+ ]
+ );
+
+ add_action('enqueue_block_editor_assets', [ self::class, 'enqueue_editor_assets' ]);
+ add_action('wp_enqueue_scripts', [ self::class, 'enqueue_frontend_assets' ]);
+ }
+
+ /**
+ * Required by the `Base_Block` class.
+ *
+ * @param array $fields Unused, required by the abstract function.
+ * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+ */
+ public function prepare_data(array $fields): array
+ {
+ return [];
+ }
+ // @phpcs:enable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
+}
diff --git a/src/Loader.php b/src/Loader.php
index 18cf3ca491..6e3bdfc4d4 100644
--- a/src/Loader.php
+++ b/src/Loader.php
@@ -162,6 +162,7 @@ public static function add_blocks(): void
new Blocks\Gallery();//NOSONAR
new Blocks\GuestBook();//NOSONAR
new Blocks\Spreadsheet();//NOSONAR
+ new Blocks\Submenu();//NOSONAR
if (!BetaBlocks::is_active()) {
return;