From d21a78a87609f4a8a9f81e0e3cdf608064916e15 Mon Sep 17 00:00:00 2001 From: Osong Agberndifor Date: Mon, 16 Dec 2024 15:12:11 +0100 Subject: [PATCH] PLANET-7652 Made Table of Content Block editable - Added code to convert ToC block to a static list - List block is used to build new content which is editable - Also added code to ensure page navigation for links when converted --- .../TableOfContents/TableOfContentsEditor.js | 207 +++++++++++++++--- assets/src/js/app.js | 6 + assets/src/js/setup_toc_navigation.js | 26 +++ .../TableOfContents/TableOfContentsStyle.scss | 81 +++++++ 4 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 assets/src/js/setup_toc_navigation.js diff --git a/assets/src/blocks/TableOfContents/TableOfContentsEditor.js b/assets/src/blocks/TableOfContents/TableOfContentsEditor.js index adb023b891..316356771c 100644 --- a/assets/src/blocks/TableOfContents/TableOfContentsEditor.js +++ b/assets/src/blocks/TableOfContents/TableOfContentsEditor.js @@ -5,53 +5,109 @@ import {makeHierarchical} from './makeHierarchical'; import {getHeadingsFromBlocks} from './getHeadingsFromBlocks'; import {deepClone} from '../../functions/deepClone'; -const {useSelect} = wp.data; -const {InspectorControls, RichText} = wp.blockEditor; -const {Button, PanelBody} = wp.components; +const TRANSLATION_ID = 'planet4-blocks-backend'; + +const BLOCK_TITLE = 'table-of-contents'; + +const BLOCK_NAME = { + TABLE_OF_CONTENTS: 'planet4-blocks/submenu', + EDITOR: 'core/block-editor', + LIST: 'core/list', + LIST_ITEM: 'core/list-item', + HEADING: 'core/heading', + GROUP: 'core/group', +}; + +const CLASS_NAME = { + HELP: 'components-base-control__help', + LIST: 'list-style', +}; + +const {useSelect, select, dispatch} = wp.data; +const {InspectorControls, RichText, BlockControls} = wp.blockEditor; +const {Button, PanelBody, ToolbarItem} = wp.components; +const {createBlock} = wp.blocks; const {__} = wp.i18n; +/** + * Renders the edit view of the Table of Contents block with controls for managing levels. + * + * @param {Object} attributes - The block attributes. + * @param {Function} setAttributes - Function to update block attributes. + * @return {JSX.Element} The rendered edit view. + */ const renderEdit = (attributes, setAttributes) => { + /** + * Adds a new level to the Table of Contents. + */ function addLevel() { const [previousLastLevel] = attributes.levels.slice(-1); const newLevel = previousLastLevel.heading + 1; setAttributes({levels: attributes.levels.concat({heading: newLevel, link: false, style: 'none'})}); } + /** + * Updates the heading level for a specific item. + * + * @param {number} index - Index of the level to update. + * @param {string} value - New heading value. + */ function onHeadingChange(index, value) { const levels = deepClone(attributes.levels); levels[index].heading = Number(value); setAttributes({levels}); } + /** + * Updates the link attribute for a specific item. + * + * @param {number} index - Index of the level to update. + * @param {string} value - New link value. + */ function onLinkChange(index, value) { const levels = deepClone(attributes.levels); levels[index].link = value; setAttributes({levels}); } + /** + * Updates the style attribute for a specific item. + * + * @param {number} index - Index of the level to update. + * @param {string} value - New style value, can be "none", "bullet", or "number". + */ function onStyleChange(index, value) { const levels = deepClone(attributes.levels); - levels[index].style = value; // Possible values: "none", "bullet", "number" + levels[index].style = value; setAttributes({levels}); } + /** + * Removes the last level from the Table of Contents. + */ function removeLevel() { setAttributes({levels: attributes.levels.slice(0, -1)}); } + /** + * Gets the minimum heading level for a specific index. + * + * @param {Object} attr - Block attributes. + * @param {number} index - Index of the level. + * @return {number|null} Minimum heading value or null for the first index. + */ function getMinLevel(attr, index) { if (index === 0) { return null; } - return attr.levels[index - 1].heading; } return ( - -

- {__('Choose the headings to be displayed in the table of contents.', 'planet4-blocks-backend')} + +

+ {__('Choose the headings to be displayed in the table of contents.', TRANSLATION_ID)}

{attributes.levels.map((level, i) => ( { disabled={attributes.levels.length >= 3 || attributes.levels.slice(-1)[0].heading === 0} style={{marginRight: 5}} > - {__('Add level', 'planet4-blocks-backend')} + {__('Add level', TRANSLATION_ID)}
- -

- + +

+ P4 Handbook P4 Table of Contents {' '} 📋 @@ -92,6 +148,14 @@ const renderEdit = (attributes, setAttributes) => { ); }; +/** + * Renders the view of the Table of Contents block. + * + * @param {Object} attributes - The block attributes. + * @param {Function} setAttributes - Function to update block attributes. + * @param {string} className - The CSS class for the block. + * @return {JSX.Element} The rendered view. + */ const renderView = (attributes, setAttributes, className) => { const { title, @@ -101,37 +165,110 @@ const renderView = (attributes, setAttributes, className) => { exampleMenuItems, } = attributes; - const blocks = useSelect(select => select('core/block-editor').getBlocks(), null); - + const blocks = useSelect(wpSelect => wpSelect(BLOCK_NAME.EDITOR).getBlocks(), null); const flatHeadings = getHeadingsFromBlocks(blocks, levels); - const menuItems = isExample ? exampleMenuItems : makeHierarchical(flatHeadings); - const style = getTableOfContentsStyle(className, submenu_style); - return ( -

- setAttributes({title: titl})} - withoutInteractiveFormatting - allowedFormats={[]} - /> - {menuItems.length > 0 ? - : -
- {__('There are not any pre-established headings that this block can display in the form of a table of content. Please add headings to your page or choose another heading size.', 'planet4-blocks-backend')} -
- } -
+ <> + + convertIntoListBlock(menuItems)} + > + {__('Convert to static list', TRANSLATION_ID)} + + +
+ setAttributes({title: titl})} + withoutInteractiveFormatting + allowedFormats={[]} + /> + {menuItems.length > 0 ? ( + + ) : ( +
+ {__('There are not any pre-established headings that this block can display in the form of a table of content. Please add headings to your page or choose another heading size.', TRANSLATION_ID)} +
+ )} +
+ ); }; +/** + * Creates a list block with list item blocks based on the given items. + * + * @param {Array} items - The items to create list blocks from. Each item should have `text`, `shouldLink`, and `children`. + * @return {Object} The core/list block with the nested structure. + */ +const createListBlocks = items => { + const innerBlocks = []; + + items.forEach(item => { + let content = item.text; + + if (item.shouldLink) { + content = `${content}`; + } + + const newInnerBlock = createBlock(BLOCK_NAME.LIST_ITEM, {className: `${CLASS_NAME.LIST} ${CLASS_NAME.LIST}-${item.style}`, content}); + + if (item.children && item.children.length > 0) { + const childListBlock = createListBlocks(item.children); + newInnerBlock.innerBlocks = [childListBlock]; + } + + innerBlocks.push(newInnerBlock); + }); + + return createBlock(BLOCK_NAME.LIST, {}, innerBlocks); +}; + +/** + * Converts the given menu items into a static list block and replaces the current block. + * + * @param {Array} menuItems - The menu items to convert into a list block. + */ +const convertIntoListBlock = menuItems => { + if (!menuItems) { + return; + } + + const blockList = select(BLOCK_NAME.EDITOR).getBlocks(); + const blockIndex = blockList.findIndex(block => block.name === BLOCK_NAME.TABLE_OF_CONTENTS); + + if (blockIndex === -1) { + return; + } + + const blockAttrs = blockList[blockIndex].attributes; + + const headingBlock = createBlock(BLOCK_NAME.HEADING, {content: blockAttrs.title}); + const listBlocks = createListBlocks(menuItems); + const groupBlock = createBlock(BLOCK_NAME.GROUP, {className: `${BLOCK_TITLE} ${blockAttrs.className}`}, [headingBlock, listBlocks]); + + dispatch(BLOCK_NAME.EDITOR).insertBlock(groupBlock, blockIndex); + dispatch(BLOCK_NAME.EDITOR).removeBlock(blockList[blockIndex].clientId); +}; + +/** + * Renders the Table of Contents block editor. + * + * @param {Object} props - The component props. + * @param {Object} props.attributes - The block attributes. + * @param {Function} props.setAttributes - Function to update block attributes. + * @param {boolean} props.isSelected - Indicates if the block is selected. + * @param {string} props.className - The CSS class for the block. + * @return {JSX.Element} The Table of Contents editor component. + */ export const TableOfContentsEditor = ({attributes, setAttributes, isSelected, className}) => ( <> {isSelected && renderEdit(attributes, setAttributes)} {renderView(attributes, setAttributes, className)} -); +); \ No newline at end of file diff --git a/assets/src/js/app.js b/assets/src/js/app.js index d989e745ca..61188e91b8 100644 --- a/assets/src/js/app.js +++ b/assets/src/js/app.js @@ -12,6 +12,7 @@ import {setupClickabelActionsListCards} from './actions_list_clickable_cards'; import {removeNoPostText} from './query-no-posts'; import {removeRelatedPostsSection} from './remove_related_section_no_posts'; import {setupCountrySelector} from './country_selector'; +import {createLinkForToCLinksToPageElements} from './setup_toc_navigation'; function requireAll(r) { r.keys().forEach(r); @@ -19,6 +20,10 @@ function requireAll(r) { requireAll(require.context('../images/icons/', true, /\.svg$/)); +// Nested lists also get returned in the first array +const tableOfContentsList = document.querySelectorAll('.table-of-contents .wp-block-list')[0]; +const allListElements = tableOfContentsList.querySelectorAll('li'); + setupCookies(); setupHeader(); setupLoadMore(); @@ -31,3 +36,4 @@ removeNoPostText(); removeRelatedPostsSection(); setupClickabelActionsListCards(); setupCountrySelector(); +createLinkForToCLinksToPageElements(allListElements); diff --git a/assets/src/js/setup_toc_navigation.js b/assets/src/js/setup_toc_navigation.js new file mode 100644 index 0000000000..d77d0cd666 --- /dev/null +++ b/assets/src/js/setup_toc_navigation.js @@ -0,0 +1,26 @@ +const updatePageElementId = (text, id) => { + const allHeadings = document.querySelectorAll('.wp-block-heading'); + allHeadings.forEach(heading => { + if (heading.textContent.trim() === text) { + heading.setAttribute('id', id); + } + }) +} + +export const createLinkForToCLinksToPageElements = (tocElements) => { + tocElements.forEach(li => { + const hasNestedUl = li.querySelector('ul'); + const isALink = li.querySelector('a'); + + if (isALink) { + const listElementText = isALink.textContent.trim(); + const idTagToSet = isALink.getAttribute('href').slice(1); + updatePageElementId(listElementText, idTagToSet); + } + + if(hasNestedUl) { + const subLists = hasNestedUl.querySelectorAll('li'); + createLinkForToCLinksToPageElements(subLists); + } + }); +} \ No newline at end of file diff --git a/assets/src/scss/blocks/TableOfContents/TableOfContentsStyle.scss b/assets/src/scss/blocks/TableOfContents/TableOfContentsStyle.scss index ad107348d9..6d4e0c374d 100644 --- a/assets/src/scss/blocks/TableOfContents/TableOfContentsStyle.scss +++ b/assets/src/scss/blocks/TableOfContents/TableOfContentsStyle.scss @@ -117,3 +117,84 @@ div[data-render="planet4-blocks/submenu"] { } } } + +.wp-block-group.table-of-contents { + 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; + + ul { + padding: 0; + } + + li { + margin: 0 !important; + list-style: none; + list-style-type: none; + font-family: var(--table-of-contents-block-menu--font-family, var(--font-family-tertiary)); + font-size: var(--text--font-size, var(--font-size-m--font-family-secondary)); + line-height: var(--text--line-height, var(--line-height-m--font-family-secondary)); + + @include x-large-and-up { + font-size: var(--text--x-large-and-up--font-size, var(--font-size-xl--font-family-secondary)); + line-height: var(--text--x-large-and-up--line-height, var(--line-height-xl--font-family-secondary)); + } + + &.list-style { + margin-inline-end: var(--table-of-contents-block-bullet-item--margin-inline-end, 0) !important; + margin-inline-start: var(--table-of-contents-block-bullet-item--margin-inline-start, 32px) !important; + + &.list-style-bullet { + list-style: disc; + } + + &.list-style-number { + list-style: decimal; + } + } + + a { + color: var(--color-text-body); + background: none; + border: 0; + + &:hover { + text-decoration: var(--link--hover--text-decoration) !important; + } + } + } + + &.is-style-sidebar { + @include medium-and-up { + float: right; + max-width: 350px; + margin-bottom: 16px; + margin-inline-start: 16px; + } + } + + &.is-style-short { + height: 250px; + + .wp-block-list { + display: flex; + justify-content: space-between; + + li ul { + display: block; + } + } + + @include medium-and-less { + .wp-block-list { + display: block; + } + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + } +}