From 3af91340decb60ef10f4da61ab8d35d3796b2376 Mon Sep 17 00:00:00 2001 From: Maud Leray Date: Wed, 18 Dec 2024 15:05:56 +0100 Subject: [PATCH] PLANET-7635 Move remaining code from plugin This is in preparation to finally retire it --- .../AssignOnlyFlatTermSelector/index.js | 187 +++++++++ .../BlockFilters/ImageBlockEdit.js | 57 +++ assets/src/block-editor/BlockFilters/index.js | 40 ++ .../QueryLoopBlockExtension/index.js | 2 +- assets/src/block-editor/TermSelector/index.js | 369 ++++++++++++++++++ .../block-editor/addButtonLinkPasteWarning.js | 18 + .../replaceTaxonomyTermSelectors.js | 25 ++ .../block-editor/setupImageBlockExtension.js | 119 ++++++ assets/src/block-templates/readme.md | 163 ++++++++ assets/src/block-templates/register.js | 8 +- .../components/Parallax/setupParallax.js | 19 + assets/src/editorIndex.js | 18 +- assets/src/frontendIndex.js | 2 + assets/src/functions/fetchJson.js | 5 + assets/src/scss/blocks.scss | 1 + .../src/scss/blocks/core-overrides/File.scss | 6 + .../scss/components/_character-counter.scss | 25 ++ .../src/scss/components/_empty-message.scss | 5 + .../scss/components/_html-sidebar-help.scss | 6 + assets/src/scss/editorStyle.scss | 3 + functions.php | 1 + src/Api/AnalyticsValues.php | 75 ++++ src/Loader.php | 15 +- 23 files changed, 1154 insertions(+), 15 deletions(-) create mode 100644 assets/src/block-editor/AssignOnlyFlatTermSelector/index.js create mode 100644 assets/src/block-editor/BlockFilters/ImageBlockEdit.js create mode 100644 assets/src/block-editor/BlockFilters/index.js create mode 100644 assets/src/block-editor/TermSelector/index.js create mode 100644 assets/src/block-editor/addButtonLinkPasteWarning.js create mode 100644 assets/src/block-editor/replaceTaxonomyTermSelectors.js create mode 100644 assets/src/block-editor/setupImageBlockExtension.js create mode 100644 assets/src/block-templates/readme.md create mode 100644 assets/src/blocks/components/Parallax/setupParallax.js create mode 100644 assets/src/scss/blocks/core-overrides/File.scss create mode 100644 assets/src/scss/components/_character-counter.scss create mode 100644 assets/src/scss/components/_empty-message.scss create mode 100644 assets/src/scss/components/_html-sidebar-help.scss create mode 100644 src/Api/AnalyticsValues.php diff --git a/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js b/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js new file mode 100644 index 0000000000..f17041f8a0 --- /dev/null +++ b/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js @@ -0,0 +1,187 @@ +/** + * The logic of this component was copied from https://github.com/WordPress/gutenberg/blob/master/packages/editor/src/components/post-taxonomies/flat-term-selector.js + * initially, then the functionality of creating non-existing terms was removed from it. + */ + +/** + * WordPress dependencies + */ +import {useEffect, useMemo, useState} from '@wordpress/element'; +import {FormTokenField, withFilters} from '@wordpress/components'; +import {useSelect, useDispatch} from '@wordpress/data'; +import {store as coreStore} from '@wordpress/core-data'; +import {useDebounce} from '@wordpress/compose'; +import {decodeEntities} from '@wordpress/html-entities'; + +const {__, _x, sprintf} = wp.i18n; + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Module constants + */ +const MAX_TERMS_SUGGESTIONS = 20; +const DEFAULT_QUERY = { + per_page: MAX_TERMS_SUGGESTIONS, + _fields: 'id,name', + context: 'view', +}; + +const isSameTermName = (termA, termB) => + decodeEntities(termA).toLowerCase() === + decodeEntities(termB).toLowerCase(); + +const termNamesToIds = (names, terms) => names.map( + termName => terms.find(term => isSameTermName(term.name, termName)).id +); + +export const AssignOnlyFlatTermSelector = ({slug}) => { + const [values, setValues] = useState([]); + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(setSearch, 500); + + const { + terms, + taxonomy, + hasAssignAction, + hasResolvedTerms, + } = useSelect( + select => { + const {getCurrentPost, getEditedPostAttribute} = select('core/editor'); + const {getEntityRecords, getTaxonomy, hasFinishedResolution} = select(coreStore); + const post = getCurrentPost(); + const _taxonomy = getTaxonomy(slug); + const _termIds = _taxonomy ? getEditedPostAttribute(_taxonomy.rest_base) : EMPTY_ARRAY; + + const query = { + ...DEFAULT_QUERY, + include: _termIds.join(','), + per_page: -1, + }; + + return { + hasAssignAction: _taxonomy ? post._links?.['wp:action-assign-' + _taxonomy.rest_base] ?? false : false, + taxonomy: _taxonomy, + termIds: _termIds, + terms: _termIds.length ? getEntityRecords('taxonomy', slug, query) : EMPTY_ARRAY, + hasResolvedTerms: hasFinishedResolution('getEntityRecords', ['taxonomy', slug, query]), + }; + }, + [slug] + ); + + const {searchResults} = useSelect( + select => { + const {getEntityRecords} = select(coreStore); + + return { + searchResults: !!search ? getEntityRecords('taxonomy', slug, { + ...DEFAULT_QUERY, + search, + }) : EMPTY_ARRAY, + }; + }, + [search, slug] + ); + + // Update terms state only after the selectors are resolved. + // We're using this to avoid terms temporarily disappearing on slow networks + // while core data makes REST API requests. + useEffect(() => { + if (hasResolvedTerms) { + const newValues = (terms ?? []).map(term => + decodeEntities(term.name) + ); + + setValues(newValues); + } + }, [terms, hasResolvedTerms]); + + const suggestions = useMemo(() => { + return (searchResults ?? []).map(term => + decodeEntities(term.name) + ); + }, [searchResults]); + + const {editPost} = useDispatch('core/editor'); + + if (!hasAssignAction) { + return null; + } + + function onUpdateTerms(newTermIds) { + editPost({[taxonomy.rest_base]: newTermIds}); + } + + function onChange(termNames) { + const availableTerms = [ + ...(terms ?? []), + ...(searchResults ?? []), + ]; + + const uniqueTerms = termNames.reduce((acc, name) => { + if ( + !acc.some(n => n.toLowerCase() === name.toLowerCase()) + ) { + acc.push(name); + } + return acc; + }, []); + + // Filter to remove new terms since we don't allow creation. + const allowedTerms = uniqueTerms.filter( + termName => availableTerms.find(term => isSameTermName(term.name, termName)) + ); + + setValues(allowedTerms); + + return onUpdateTerms(termNamesToIds(allowedTerms, availableTerms)); + } + + const newTermLabel = + taxonomy?.labels?.add_new_item ?? + (slug === 'post_tag' ? __('Add new tag') : __('Add new Term')); + const singularName = + taxonomy?.labels?.singular_name ?? + (slug === 'post_tag' ? __('Tag') : __('Term')); + const termAddedLabel = sprintf( + /* translators: %s: term name. */ + _x('%s added', 'term'), + singularName + ); + const termRemovedLabel = sprintf( + /* translators: %s: term name. */ + _x('%s removed', 'term'), + singularName + ); + const removeTermLabel = sprintf( + /* translators: %s: term name. */ + _x('Remove %s', 'term'), + singularName + ); + + return ( + + ); +}; + +export default withFilters('editor.PostTaxonomyType')(AssignOnlyFlatTermSelector); diff --git a/assets/src/block-editor/BlockFilters/ImageBlockEdit.js b/assets/src/block-editor/BlockFilters/ImageBlockEdit.js new file mode 100644 index 0000000000..7b0839be1d --- /dev/null +++ b/assets/src/block-editor/BlockFilters/ImageBlockEdit.js @@ -0,0 +1,57 @@ +/** + * Return an editable image block + * + * - display image credits in caption during edition + * + * @param {Object} BlockEdit + * @return {Object} interface to edit images on the Editor + */ +export const ImageBlockEdit = BlockEdit => props => { + if ('core/image' !== props.name) { + return ; + } + + const {attributes, clientId} = props; + const {id, className = ''} = attributes; + + // Get image data + const image = wp.data.useSelect(select => id ? select('core').getMedia(id) : null); + const credits = image?.meta?._credit_text; + const captionText = image?.caption?.raw; + // Compile data for insertion + let image_credits = null; + if (credits && credits.length > 0 && (!captionText || !captionText.includes(credits))) { + image_credits = credits.includes('©') ? credits : `© ${credits}`; + } + + const block_id = clientId ? `block-${clientId}` : null; + + // Update width and height when sized rounded styles are selected + if (className.includes('is-style-rounded-')) { + const classes = className.split(' '); + const size = classes.find(c => c.includes('is-style-rounded-')).replace('is-style-rounded-', '') || 180; + attributes.width = parseInt(size); + attributes.height = parseInt(size); + } + + // Force to use square images when the class `square-*` is added + if (className.includes('square-')) { + const size = className.slice(className.search('square-') + 'square-'.length).split(' ')[0] || 180; + attributes.width = parseInt(size); + attributes.height = parseInt(size); + } + + return ( + <> + + {block_id && image_credits && ( + captionText ? + : +
{image_credits}
+ )} + + ); +}; diff --git a/assets/src/block-editor/BlockFilters/index.js b/assets/src/block-editor/BlockFilters/index.js new file mode 100644 index 0000000000..947316e2cc --- /dev/null +++ b/assets/src/block-editor/BlockFilters/index.js @@ -0,0 +1,40 @@ +const {addFilter} = wp.hooks; + +import {ImageBlockEdit} from './ImageBlockEdit'; + +export const addBlockFilters = () => { + addFileBlockFilter(); + addImageBlockFilter(); + addGravityFormsBlockFilter(); +}; + +const addFileBlockFilter = () => { + const setDownloadButtonToggleDefaultFalse = (settings, name) => { + if ('core/file' !== name) { + return settings; + } + + settings.attributes.showDownloadButton.default = false; + + return settings; + }; + + addFilter('blocks.registerBlockType', 'planet4-master-theme/filters/file', setDownloadButtonToggleDefaultFalse); +}; + +const addImageBlockFilter = () => addFilter('editor.BlockEdit', 'core/image/edit', ImageBlockEdit); + +// Enforce "AJAX" toggle setting enabled by default, on Gravity form block. +const addGravityFormsBlockFilter = () => { + const setAJAXToggleDefaultTrue = (settings, name) => { + if ('gravityforms/form' !== name) { + return settings; + } + + settings.attributes.ajax.default = true; + + return settings; + }; + + addFilter('blocks.registerBlockType', 'planet4-master-theme/filters/gravity-form', setAJAXToggleDefaultTrue); +}; diff --git a/assets/src/block-editor/QueryLoopBlockExtension/index.js b/assets/src/block-editor/QueryLoopBlockExtension/index.js index adc5ac53d8..314b07d83b 100644 --- a/assets/src/block-editor/QueryLoopBlockExtension/index.js +++ b/assets/src/block-editor/QueryLoopBlockExtension/index.js @@ -15,7 +15,7 @@ export const setupQueryLoopBlockExtension = () => { addFilter( 'editor.BlockEdit', - 'planet4-blocks/overrides/query-loop-layout', + 'planet4-master-theme/overrides/query-loop-layout', createHigherOrderComponent( BlockEdit => props => { const {attributes, setAttributes} = props; diff --git a/assets/src/block-editor/TermSelector/index.js b/assets/src/block-editor/TermSelector/index.js new file mode 100644 index 0000000000..b4187d150c --- /dev/null +++ b/assets/src/block-editor/TermSelector/index.js @@ -0,0 +1,369 @@ +// This component is based on the one used by WordPress for categories (without the hierarchical aspect, since we don't need it): +// https://github.com/WordPress/gutenberg/blob/trunk/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js + +/** + * WordPress dependencies + */ +import {useMemo, useState} from '@wordpress/element'; +import { + Button, + CheckboxControl, + TextControl, + withFilters, +} from '@wordpress/components'; +import {useDispatch, useSelect} from '@wordpress/data'; +import {useDebounce} from '@wordpress/compose'; +import {store as coreStore} from '@wordpress/core-data'; +import {decodeEntities} from '@wordpress/html-entities'; + +const {__, _n, _x, sprintf} = wp.i18n; +const {speak} = wp.a11y; + +/** + * Module Constants + */ +const DEFAULT_QUERY = { + per_page: -1, + orderby: 'name', + order: 'asc', + _fields: 'id,name', + context: 'view', +}; + +const MIN_TERMS_COUNT_FOR_FILTER = 8; + +const EMPTY_ARRAY = []; + +/** + * Sort Terms by Selected. + * + * @param {Object[]} termsTree Array of terms in tree format. + * @param {number[]} terms Selected terms. + * + * @return {Object[]} Sorted array of terms. + */ +export function sortBySelected(termsTree, terms) { + const treeHasSelection = termTree => { + return terms.indexOf(termTree.id) !== -1; + }; + + const termIsSelected = (termA, termB) => { + const termASelected = treeHasSelection(termA); + const termBSelected = treeHasSelection(termB); + + if (termASelected === termBSelected) { + return 0; + } + + if (termASelected && !termBSelected) { + return -1; + } + + if (!termASelected && termBSelected) { + return 1; + } + + return 0; + }; + const newTermTree = [...termsTree]; + newTermTree.sort(termIsSelected); + return newTermTree; +} + +/** + * Find term by name. + * + * @param {Object[]} terms Array of Terms. + * @param {string} name Term name. + * + * @return {Object} Term object. + */ +export function findTerm(terms, name) { + return terms.find(terms, term => term.name.toLowerCase() === name.toLowerCase()); +} + +/** + * Get filter matcher function. + * + * @param {string} filterValue Filter value. + * @return {(function(Object): (Object|boolean))} Matcher function. + */ +export function getFilterMatcher(filterValue) { + const matchTermsForFilter = originalTerm => { + if ('' === filterValue) { + return originalTerm; + } + + // If the term's name contains the filterValue then return it. + if (-1 !== originalTerm.name.toLowerCase().indexOf(filterValue.toLowerCase())) { + return originalTerm; + } + + // Otherwise, return false. After mapping, the list of terms will need + // to have false values filtered out. + return false; + }; + return matchTermsForFilter; +} + +/** + * Term selector. + * + * @param {Object} props Component props. + * @param {string} props.slug Taxonomy slug. + * @return {WPElement} Term selector component. + */ +export function TermSelector({slug}) { + const [adding, setAdding] = useState(false); + const [formName, setFormName] = useState(''); + /** + * @type {*} + */ + const [showForm, setShowForm] = useState(false); + const [filterValue, setFilterValue] = useState(''); + const [filteredTermsTree, setFilteredTermsTree] = useState([]); + const debouncedSpeak = useDebounce(speak, 500); + + const { + hasCreateAction, + hasAssignAction, + terms, + loading, + availableTerms, + taxonomy, + isUserAdmin, + } = useSelect( + select => { + const {getCurrentPost, getEditedPostAttribute} = select('core/editor'); + const {getTaxonomy, getEntityRecords, isResolving, canUser} = select(coreStore); + const _taxonomy = getTaxonomy(slug); + const post = getCurrentPost(); + + return { + isUserAdmin: canUser('create', 'users') ?? false, + hasCreateAction: _taxonomy ? + post._links?.[ + 'wp:action-create-' + _taxonomy.rest_base + ] ?? false : + false, + hasAssignAction: _taxonomy ? + post._links?.[ + 'wp:action-assign-' + _taxonomy.rest_base + ] ?? false : + false, + terms: _taxonomy ? + getEditedPostAttribute(_taxonomy.rest_base) : + EMPTY_ARRAY, + loading: isResolving('getEntityRecords', [ + 'taxonomy', + slug, + DEFAULT_QUERY, + ]), + availableTerms: + getEntityRecords('taxonomy', slug, DEFAULT_QUERY) || + EMPTY_ARRAY, + taxonomy: _taxonomy, + }; + }, + [slug] + ); + + const {saveEntityRecord} = useDispatch(coreStore); + const {editPost} = useDispatch('core/editor'); + + const availableTermsTree = useMemo( + () => sortBySelected(availableTerms, terms), + // Remove `terms` from the dependency list to avoid reordering every time + // checking or unchecking a term. + [availableTerms] + ); + + if (!hasAssignAction) { + return null; + } + + /** + * Append new term. + * + * @param {Object} term Term object. + * @return {Promise} A promise that resolves to save term object. + */ + const addTerm = term => { + return saveEntityRecord('taxonomy', slug, term); + }; + + /** + * Update terms for post. + * + * @param {number[]} termIds Term ids. + */ + const onUpdateTerms = termIds => { + editPost({[taxonomy.rest_base]: termIds}); + }; + + /** + * Handler for checking term. + * + * @param {number} termId + */ + const onChange = termId => { + const hasTerm = terms.includes(termId); + const newTerms = hasTerm ? + terms.filter(id => id !== termId) : + [...terms, termId]; + onUpdateTerms(newTerms); + }; + + const onChangeFormName = value => { + setFormName(value); + }; + + const onToggleForm = () => { + setShowForm(!showForm); + }; + + const onAddTerm = async event => { + event.preventDefault(); + if (formName === '' || adding) { + return; + } + + // Check if the term we are adding already exists. + const existingTerm = findTerm(availableTerms, formName); + if (existingTerm) { + // If the term we are adding exists but is not selected select it. + if (!terms.some(term => term === existingTerm.id)) { + onUpdateTerms([...terms, existingTerm.id]); + } + + setFormName(''); + + return; + } + setAdding(true); + + const newTerm = await addTerm({ + name: formName, + }); + + const defaultName = slug === 'category' ? __('Category') : __('Term'); + const termAddedMessage = sprintf( + /* translators: %s: taxonomy name */ + _x('%s added', 'term'), + taxonomy?.labels?.singular_name ?? defaultName + ); + speak(termAddedMessage, 'assertive'); + setAdding(false); + setFormName(''); + onUpdateTerms([...terms, newTerm.id]); + }; + + const setFilter = value => { + const newFilteredTermsTree = availableTermsTree + .map(getFilterMatcher(value)) + .filter(term => term); + + setFilterValue(value); + setFilteredTermsTree(newFilteredTermsTree); + + const resultCount = newFilteredTermsTree.length; + const resultsFoundMessage = sprintf( + /* translators: %d: number of results */ + _n('%d result found.', '%d results found.', resultCount), + resultCount + ); + + debouncedSpeak(resultsFoundMessage, 'assertive'); + }; + + const renderTerms = renderedTerms => renderedTerms.map(term => ( +
+ { + const termId = parseInt(term.id, 10); + onChange(termId); + }} + label={decodeEntities(term.name)} + /> +
+ )); + + const labelWithFallback = ( + labelProperty, + fallbackIsTag, + fallbackIsNotTag + ) => + taxonomy?.labels?.[labelProperty] ?? + (slug === 'post_tag' ? fallbackIsTag : fallbackIsNotTag); + const newTermButtonLabel = labelWithFallback( + 'add_new_item', + __('Add new tag'), + __('Add new term') + ); + const newTermLabel = labelWithFallback( + 'new_item_name', + __('Add new tag'), + __('Add new term') + ); + const newTermSubmitLabel = newTermButtonLabel; + const filterLabel = taxonomy?.labels?.search_items ?? __('Search Terms'); + const groupLabel = taxonomy?.name ?? __('Terms'); + const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER; + + return ( + <> + { showFilter && ( + + ) } +
+ { renderTerms( + '' !== filterValue ? filteredTermsTree : availableTermsTree + ) } +
+ {/* Only admins should be allowed to create new tags */} + { !loading && hasCreateAction && (isUserAdmin || slug !== 'post_tag') && ( + + ) } + {!isUserAdmin && slug === 'post_tag' &&

{__('New tags can only be created by an administrator', 'planet4-blocks-backend')}

} + { showForm && ( +
+ + + + ) } + + ); +} + +export default withFilters('editor.PostTaxonomyType')(TermSelector); diff --git a/assets/src/block-editor/addButtonLinkPasteWarning.js b/assets/src/block-editor/addButtonLinkPasteWarning.js new file mode 100644 index 0000000000..fe8a114dfd --- /dev/null +++ b/assets/src/block-editor/addButtonLinkPasteWarning.js @@ -0,0 +1,18 @@ +export const addButtonLinkPasteWarning = () => document.addEventListener('DOMContentLoaded', () => { + document.onpaste = event => { + const {target} = event; + if (!target.matches('.wp-block-button__link, .wp-block-button__link *')) { + return; + } + + const aTags = target.querySelectorAll('a'); + if (aTags.length) { + const {__} = wp.i18n; + // eslint-disable-next-line no-alert + alert(__( + 'You are pasting a link into the button text. Please ensure your clipboard only has text in it. Alternatively you can press control/command + SHIFT + V to paste only the text in your clipboard.', + 'planet4-blocks-backend' + )); + } + }; +}); diff --git a/assets/src/block-editor/replaceTaxonomyTermSelectors.js b/assets/src/block-editor/replaceTaxonomyTermSelectors.js new file mode 100644 index 0000000000..6be2757a68 --- /dev/null +++ b/assets/src/block-editor/replaceTaxonomyTermSelectors.js @@ -0,0 +1,25 @@ +import {AssignOnlyFlatTermSelector} from './AssignOnlyFlatTermSelector'; +import {TermSelector} from './TermSelector'; + +const customizeTaxonomySelectors = OriginalComponent => props => { + // For following taxonomies it should not be possible to create new terms on the post edit page + const isCustomComponent = ['p4-page-type', 'post_tag'].includes(props.slug); + + let component = OriginalComponent; + if (isCustomComponent) { + component = props.slug === 'post_tag' ? TermSelector : AssignOnlyFlatTermSelector; + } + + return wp.element.createElement( + component, + props + ); +}; + +export const replaceTaxonomyTermSelectors = () => { + wp.hooks.addFilter( + 'editor.PostTaxonomyType', + 'planet4-master-theme', + customizeTaxonomySelectors + ); +}; diff --git a/assets/src/block-editor/setupImageBlockExtension.js b/assets/src/block-editor/setupImageBlockExtension.js new file mode 100644 index 0000000000..d6efc1ddf0 --- /dev/null +++ b/assets/src/block-editor/setupImageBlockExtension.js @@ -0,0 +1,119 @@ +import {Button, ButtonGroup, PanelBody} from '@wordpress/components'; +import {InspectorControls} from '@wordpress/block-editor'; + +const {addFilter} = wp.hooks; +const {__} = wp.i18n; + +// Enable spacing control on the following blocks +const targetBlocks = [ + 'core/image', +]; + +const captionStyleOptions = [ + { + label: __('Medium'), + value: 'medium', + }, +]; + +const captionAlignmentOptions = [ + { + label: __('Left'), + value: 'left', + }, + { + label: __('Center'), + value: 'center', + }, + { + label: __('Right'), + value: 'right', + }, +]; + +export const setupImageBlockExtension = () => { + addExtraAttributes(); + addExtraControls(); +}; + +const addExtraAttributes = () => { + const addCaptionStyleAttributes = (settings, name) => { + // Do nothing if it's another block than our defined ones. + if (!targetBlocks.includes(name)) { + return settings; + } + + settings.attributes = { + ...settings.attributes, + captionStyle: { + type: 'string', + default: captionStyleOptions[0].value, + }, + captionAlignment: { + type: 'string', + default: captionAlignmentOptions[1].value, + }, + }; + + return settings; + }; + + addFilter('blocks.registerBlockType', 'planet4-blocks/overrides/image', addCaptionStyleAttributes); +}; + +const addExtraControls = () => { + const {createHigherOrderComponent} = wp.compose; + + const withCaptionStyle = createHigherOrderComponent(BlockEdit => props => { + // Do nothing if it's another block than our defined ones. + if (!targetBlocks.includes(props.name)) { + return ( + + ); + } + + const {captionAlignment} = props.attributes; + + const updateCaptionAlignment = value => { + const className = props.attributes.className || ''; + const classNameBase = className.split('caption-alignment-')[0]; + const newClassName = classNameBase + ` caption-alignment-${value}`; + props.setAttributes({className: newClassName}); + }; + + return ( + <> + + + + {/* eslint-disable-next-line jsx-a11y/label-has-for, jsx-a11y/label-has-associated-control */} + + + { + captionAlignmentOptions.map((option, key) => { + return ; + }) + } + + + + + ); + }, 'withCaptionStyle'); + + addFilter('editor.BlockEdit', 'planet4-master-theme/overrides/image-controls', withCaptionStyle); +}; diff --git a/assets/src/block-templates/readme.md b/assets/src/block-templates/readme.md new file mode 100644 index 0000000000..2a37a3ef1c --- /dev/null +++ b/assets/src/block-templates/readme.md @@ -0,0 +1,163 @@ +# Block templates + +[Block templates](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-templates/) are a declaration of a list of blocks. + +Their content is: +- expanded _like Block patterns_, once it's in the editor it won't change, even if the template declaration changes +- generated by the Editor _like Blocks_, so + - you only have to manage declaration, _not_ the output + - you can easily reuse that declaration + +## Using a Block template + +Block templates appear in the _Blocks_ tab of the _Blocks selector_ in the Editor. + +They can also be copy/pasted in their self closing delimiter form +```html + +``` +and can support attributes +```html + +``` +This inline version is expanded with its generated content during post editing. + +## Creating a Block template + +- Create a folder with your BT name +- Add 3 files in it + - `block.json` is the basic declaration of the block, the same way used [to register block types](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/) + - `template.js` should return a `template()` function that contains the minimal definition of the template + - `index.js` exports all data +- List it in `template-list.js`. That way it will be registered by the editorIndex.js script during Editor loading. + +### Example of minimal Block template + +_my-template/block.json_ +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "planet4-block-templates/my-template", + "title": "My template", + "description": "My template", + "category": "planet4", + "textdomain": "planet4-blocks-backend" +} +``` + +_my-template/template.js_ +```js +// A list of blocks +// A block is [ name, attributes, inner blocks ] ] +const template = () => ([ + ['core/heading', {level: 1, placeholder: 'Enter title'}], + ['core/group', {}, [ + ['core/paragraph', {placeholder: 'Enter description'}], + ['core/buttons', {}, [ + ['core/button'] + ]] + ]] +]); + +export default template; +``` + +_my-template/index.js_ +```js +import metadata from './block.json'; +import template from './template'; + +export { metadata, template }; +``` + +## Adding parameters to you Block template + +Adding parameters allows you to easily reuse a template content into other blocks or templates via import. + +_my-template/template.js_ +```js +// A list of blocks +// A block is [ name, attributes, inner blocks ] ] +const template = ({ + title: null, + buttonText: null +}) => ([ + ['core/heading', {level: 1, placeholder: 'Enter title', content: title}], + ['core/group', {}, [ + ['core/paragraph', {placeholder: 'Enter description'}], + ['core/buttons', {}, [ + ['core/button', {text: buttonText}] + ]] + ]] +]); + +export default template; +``` + +To use those parameters as block attributes, you need to allow those attributes in your block declaration. + +_my-template/block.json_ +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "planet4-block-templates/my-template", + "title": "My template", + "description": "My template", + "category": "planet4", + "textdomain": "planet4-blocks-backend", + "attributes": { + "title": { "type": "string" }, + "buttonText": { "type": "string" } + } +} +``` + +## Declaring your Block template into a Block pattern + +To have a Block pattern selectable in the _Patterns_ tab of the Blocks selector, you can just use your block template as a self closing block delimiter. +This declaration will be expanded by the Editor on insertion; once expanded, an update of the template declaration will not update its existing content in posts, and it will stay identified in the _List view_. +It can use the attributes you declared for your Block template. + +_classes/patterns/class-mytemplate.php_ +```php + __( 'My template', 'planet4-blocks-backend' ), + 'categories' => [ 'planet4' ], + 'content' => '', + ]; + } +} + +``` + +## Locking a Block template + +Export a `templateLock` attribute, following the [templateLock definition](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-templates/#locking) + +```js +import metadata from './block.json'; +import template from './template'; + +const templateLock = 'all'; // 'all', 'insert', false + +export { metadata, template, templateLock }; +``` + +## Removing a Block template + +If we decide to remove a Block template from the available templates, it will trigger an error in the Visual editor "Your site doesn't include support for the `` block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely". +To avoid this situation, any Block template removal should include a database patch that will remove the html comments describing the block template (ie `` and closing tag) in post content. The content between those tags should stay the same. diff --git a/assets/src/block-templates/register.js b/assets/src/block-templates/register.js index 5d5f594697..b8de879245 100644 --- a/assets/src/block-templates/register.js +++ b/assets/src/block-templates/register.js @@ -2,7 +2,7 @@ import edit from './edit'; import save from './save'; import templateList from './template-list'; -const {registerBlockType, getBlockTypes} = wp.blocks; +const {registerBlockType} = wp.blocks; const {getCurrentPostType} = wp.data.select('core/editor'); const setSupport = metadata => { @@ -26,12 +26,6 @@ export const registerBlockTemplates = blockTemplates => { // eslint-disable-next-line prefer-const let {metadata, template, templateLock = false} = blockTemplate; - // To be removed when we finally migrate all patterns to master theme - const blockAlreadyExists = getBlockTypes().find(block => block.name === metadata.name); - if (blockAlreadyExists) { - return; - } - if (metadata.postTypes && !metadata.postTypes.includes(postType)) { return null; } diff --git a/assets/src/blocks/components/Parallax/setupParallax.js b/assets/src/blocks/components/Parallax/setupParallax.js new file mode 100644 index 0000000000..05177953a1 --- /dev/null +++ b/assets/src/blocks/components/Parallax/setupParallax.js @@ -0,0 +1,19 @@ +export const setupParallax = () => { + const parallaxImages = document.querySelectorAll('.is-style-parallax img'); + + if (!parallaxImages.length) { + return; + } + + let mobileSpeedAllSetup = false; + parallaxImages.forEach((image, index) => { + image.setAttribute('data-rellax-xs-speed', -1); // the default value is -2, we want to keep it for bigger screens. + if (index === parallaxImages.length - 1) { + mobileSpeedAllSetup = true; + } + }); + + if (mobileSpeedAllSetup) { + return new Rellax('.is-style-parallax img', {center: true}); // eslint-disable-line no-undef + } +}; diff --git a/assets/src/editorIndex.js b/assets/src/editorIndex.js index 728bc7a7c7..956c99a916 100644 --- a/assets/src/editorIndex.js +++ b/assets/src/editorIndex.js @@ -12,6 +12,10 @@ import {registerColumnsBlock} from './blocks/Columns/ColumnsBlock'; import {registerBlockStyles} from './block-styles'; import {registerBlockVariations} from './block-variations'; import {registerActionButtonTextBlock} from './blocks/ActionCustomButtonText'; +import {addButtonLinkPasteWarning} from './block-editor/addButtonLinkPasteWarning'; +import {addBlockFilters} from './block-editor/BlockFilters'; +import {replaceTaxonomyTermSelectors} from './block-editor/replaceTaxonomyTermSelectors'; +import {setupImageBlockExtension} from './block-editor/setupImageBlockExtension'; wp.domReady(() => { // Blocks @@ -37,10 +41,12 @@ wp.domReady(() => { // Block variations registerBlockVariations(); -}); - -setupCustomSidebar(); -// Setup new attributes to the core/query. -// It should be executed after the DOM is ready -setupQueryLoopBlockExtension(); + // Editor behaviour. + setupQueryLoopBlockExtension(); + setupCustomSidebar(); + addButtonLinkPasteWarning(); + addBlockFilters(); + replaceTaxonomyTermSelectors(); + setupImageBlockExtension(); +}); diff --git a/assets/src/frontendIndex.js b/assets/src/frontendIndex.js index 00610e2987..3d65648e20 100644 --- a/assets/src/frontendIndex.js +++ b/assets/src/frontendIndex.js @@ -8,6 +8,7 @@ import {TableOfContentsFrontend} from './blocks/TableOfContents/TableOfContentsF import {HappyPointFrontend} from './blocks/HappyPoint/HappyPointFrontend'; import {ColumnsFrontend} from './blocks/Columns/ColumnsFrontend'; import {setupLightboxForImages} from './blocks/components/Lightbox/setupLightboxForImages'; +import {setupParallax} from './blocks/components/Parallax/setupParallax'; // Render React components const COMPONENTS = { @@ -35,4 +36,5 @@ document.addEventListener('DOMContentLoaded', () => { ); setupLightboxForImages(); + setupParallax(); }); diff --git a/assets/src/functions/fetchJson.js b/assets/src/functions/fetchJson.js index 7fa925139d..b94143515b 100644 --- a/assets/src/functions/fetchJson.js +++ b/assets/src/functions/fetchJson.js @@ -1,3 +1,8 @@ +// This ESLint error is disabled since 'regenerator-runtime/runtime' has already been added by another package. +// There is no need to explicitly include it in the list of dependencies in the package.json file. +// eslint-disable-next-line import/no-extraneous-dependencies +import 'regenerator-runtime/runtime'; + /** * Function with a similar signature as WordPress's apiFetch, but doesn't do a bunch of things we don't need and cause * issues. You could as well use what is inside this function directly, but having this in a single function makes it diff --git a/assets/src/scss/blocks.scss b/assets/src/scss/blocks.scss index 404dd70073..c813c161c9 100644 --- a/assets/src/scss/blocks.scss +++ b/assets/src/scss/blocks.scss @@ -29,6 +29,7 @@ @import "blocks/core-overrides/Columns"; @import "blocks/core-overrides/Heading"; @import "blocks/core-overrides/Embed"; +@import "blocks/core-overrides/File"; // Other @import "blocks/WideBlocks"; diff --git a/assets/src/scss/blocks/core-overrides/File.scss b/assets/src/scss/blocks/core-overrides/File.scss new file mode 100644 index 0000000000..165644cfc5 --- /dev/null +++ b/assets/src/scss/blocks/core-overrides/File.scss @@ -0,0 +1,6 @@ +.wp-block-file .wp-block-file__button { + overflow: unset; + padding: 0 1em; + margin-inline-start: 1em; + font-size: .8em; +} diff --git a/assets/src/scss/components/_character-counter.scss b/assets/src/scss/components/_character-counter.scss new file mode 100644 index 0000000000..a230e84869 --- /dev/null +++ b/assets/src/scss/components/_character-counter.scss @@ -0,0 +1,25 @@ +.counted-field-wrapper { + position: relative; + margin-bottom: 16px; + + textarea { + // Needed to ensure same spacing as input[type=text]. + display: block; + } +} + +.character-counter { + position: absolute; + bottom: -1.7em; + right: 0; + font-size: .7em; + color: var(--grey-800); + + &.character-limit-exceeded { + color: var(--red-500); + } + + &.character-limit-warning { + color: orange; + } +} diff --git a/assets/src/scss/components/_empty-message.scss b/assets/src/scss/components/_empty-message.scss new file mode 100644 index 0000000000..1e30e58f24 --- /dev/null +++ b/assets/src/scss/components/_empty-message.scss @@ -0,0 +1,5 @@ +.EmptyMessage { + font-family: var(--font-family-primary); + padding: $sp-4; + background: var(--gp-green-200); +} diff --git a/assets/src/scss/components/_html-sidebar-help.scss b/assets/src/scss/components/_html-sidebar-help.scss new file mode 100644 index 0000000000..367b645632 --- /dev/null +++ b/assets/src/scss/components/_html-sidebar-help.scss @@ -0,0 +1,6 @@ +.HTMLSidebarHelp { + padding-top: $sp-1; + padding-bottom: $sp-2; + font-size: 13px; + line-height: 1.5; +} diff --git a/assets/src/scss/editorStyle.scss b/assets/src/scss/editorStyle.scss index da5a9a83ad..5145dc08a5 100644 --- a/assets/src/scss/editorStyle.scss +++ b/assets/src/scss/editorStyle.scss @@ -16,6 +16,9 @@ @import "components/buttons"; @import "components/share-buttons"; @import "components/image-hover-buttons"; +@import "components/empty-message"; +@import "components/character-counter"; +@import "components/html-sidebar-help"; @import "base/typography"; @import "layout/tables"; @import "layout/page-header"; diff --git a/functions.php b/functions.php index 4781aeaa3c..f9e5546102 100644 --- a/functions.php +++ b/functions.php @@ -105,6 +105,7 @@ function (): void { Api\Covers::register_endpoint(); Api\Articles::register_endpoint(); Api\SocialMedia::register_endpoint(); + Api\AnalyticsValues::register_endpoint(); } ); diff --git a/src/Api/AnalyticsValues.php b/src/Api/AnalyticsValues.php new file mode 100644 index 0000000000..04a854cd81 --- /dev/null +++ b/src/Api/AnalyticsValues.php @@ -0,0 +1,75 @@ + WP_REST_Server::READABLE, + 'permission_callback' => static function () { + return current_user_can('edit_posts'); + }, + 'callback' => static function ($request) { + $post_id = (int) $request->get_param('id'); + + $analytics_values = AV::from_cache_or_api_or_hardcoded(); + + $global_options = $analytics_values->global_projects_options($post_id); + $local_options = $analytics_values->local_projects_options($post_id); + $basket_options = $analytics_values->basket_options(); + + return rest_ensure_response( + [ + [ + 'global_projects' => array_map( + fn ($k, $v) => [ + 'label' => $v, + 'value' => $k, + ], + array_keys($global_options), + array_values($global_options) + ), + 'local_projects' => array_map( + fn ($k, $v) => [ + 'label' => $v, + 'value' => $k, + ], + array_keys($local_options), + array_values($local_options) + ), + 'baskets' => array_map( + fn ($k, $v) => [ + 'label' => $v, + 'value' => $k, + ], + array_keys($basket_options), + array_values($basket_options) + ), + ], + ] + ); + }, + ], + ], + ); + } +} diff --git a/src/Loader.php b/src/Loader.php index f2e5130ff5..effe70c84d 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -56,8 +56,21 @@ private function __construct(array $services) $this->load_block_services(); Commands::load(); - // During PLANET-6373 transition, priority between theme and plugin matters. add_action('init', [self::class, 'add_blocks'], 20); + + // Load parallax library for Media & Text block. + add_action( + 'wp_enqueue_scripts', + static function (): void { + wp_enqueue_script( + 'rellax', + 'https://cdnjs.cloudflare.com/ajax/libs/rellax/1.12.1/rellax.min.js', + [], + '1.12.1', + true + ); + } + ); } /**