diff --git a/src/block/icon-list-item/edit.js b/src/block/icon-list-item/edit.js index 1384881de..546d0b0eb 100644 --- a/src/block/icon-list-item/edit.js +++ b/src/block/icon-list-item/edit.js @@ -4,10 +4,12 @@ import { TextStyles } from './style' import { getUseSvgDef } from '../icon-list-new/util' import { - useOutdentListItem, + convertToListItems, useIndentListItem, + useOutdentListItem, useMerge, useOnSplit, + useCopy, } from './util' /** @@ -53,8 +55,8 @@ const Edit = props => { attributes, clientId, isSelected, - onRemove, onReplace, + mergeBlocks, context, className, setAttributes, @@ -101,18 +103,17 @@ const Edit = props => { const onSplit = useOnSplit( clientId, attributes ) - const onMerge = useMerge( blockContext, clientId, attributes.text ) - - //TODO: move cursor to adjacent blocks without double press of arrow keys + const onMerge = useMerge( clientId, mergeBlocks ) const blockProps = useBlockProps( { + ref: useCopy( clientId ), blockHoverClass: props.blockHoverClass, clientId: props.clientId, attributes: props.attributes, className: blockClassNames, blockTag: 'li', renderHtmlTag: false, - tabindex: '-1', + tabIndex: '-1', } ) const { ref, ...innerBlocksProps } = useInnerBlocksProps( blockProps, { @@ -188,9 +189,15 @@ const Edit = props => { tagName="span" className={ textClassNames } onSplit={ onSplit } - onRemove={ onRemove } onMerge={ onMerge } - onReplace={ onReplace } + onReplace={ onReplace + ? ( blocks, ...args ) => { + onReplace( + convertToListItems( blocks ), + ...args + ) + } + : undefined } /> { innerBlocksProps.children } diff --git a/src/block/icon-list-item/index.js b/src/block/icon-list-item/index.js index cbc9ff42a..0e0b8d4ce 100644 --- a/src/block/icon-list-item/index.js +++ b/src/block/icon-list-item/index.js @@ -19,8 +19,15 @@ export const settings = { supports: { anchor: true, align: true, + __experimentalSelector: 'li', }, example, edit, save, + merge( attributes, attributesToMerge ) { + return { + ...attributes, + text: attributes.text + attributesToMerge.text, + } + }, } diff --git a/src/block/icon-list-item/util.js b/src/block/icon-list-item/util.js index 19a647a9e..28a0b65c3 100644 --- a/src/block/icon-list-item/util.js +++ b/src/block/icon-list-item/util.js @@ -2,13 +2,47 @@ /** * WordPress dependencies */ +import { useRefEffect } from '@wordpress/compose' import { useCallback } from '@wordpress/element' import { - useSelect, useDispatch, useRegistry, + useSelect, useDispatch, useRegistry, select, } from '@wordpress/data' -import { createBlock, cloneBlock } from '@wordpress/blocks' +import { + createBlock, cloneBlock, switchToBlockType, +} from '@wordpress/blocks' +import { store as blockEditorStore } from '@wordpress/block-editor' import { useBlockContext } from '~stackable/hooks' +function convertBlockToList( block ) { + const list = switchToBlockType( block, 'stackable/icon-list-new' ) + if ( list ) { + return list + } + const paragraph = switchToBlockType( block, 'core/paragraph' ) + if ( ! paragraph ) { + return null + } + return switchToBlockType( paragraph, 'stackable/icon-list-new' ) +} + +export function convertToListItems( blocks ) { + const listItems = [] + + for ( let block of blocks ) { + if ( block.name === 'stackable/icon-list-item' ) { + listItems.push( block ) + } else if ( block.name === 'stackable/icon-list-new' ) { + listItems.push( ...block.innerBlocks ) + } else if ( ( block = convertBlockToList( block ) ) ) { + for ( const { innerBlocks } of block ) { + listItems.push( ...innerBlocks ) + } + } + } + + return listItems +} + export const useOutdentListItem = ( blockContext, clientId ) => { const { parentBlock, @@ -106,64 +140,119 @@ export const useIndentListItem = ( blockContext, clientId ) => { }, [ blockContext, clientId ] ) } -export const useMerge = ( blockContext, clientId, text ) => { - const { - parentBlock, - previousBlock, - hasInnerBlocks, - innerBlocks, - } = blockContext +// Modified useMerge from gutenberg list item hooks. +export const useMerge = ( clientId, onMerge ) => { + const blockContext = useBlockContext( clientId ) + const { previousBlock } = blockContext const registry = useRegistry() const outdentListItem = useOutdentListItem( blockContext, clientId ) - const { - updateBlockAttributes, - removeBlock, - moveBlocksToPosition, - } = - useDispatch( 'core/block-editor' ) + const { mergeBlocks, moveBlocksToPosition } = useDispatch( 'core/block-editor' ) - const { - getBlockAttributes, - } = useSelect( 'core/block-editor' ) + const { getBlockOrder, getBlockRootClientId } = useSelect( 'core/block-editor' ) + + const getParentListItemId = id => { + const { parentBlock: parentListBlock } = select( 'stackable/block-context' ).getBlockContext( id ) + const { parentBlock: parentIconListItemBlock } = select( 'stackable/block-context' ).getBlockContext( parentListBlock.clientId ) + + if ( parentIconListItemBlock?.name !== 'stackable/icon-list-item' ) { + return + } - let blockToMerge = previousBlock - let willOutdent = false - const { parentBlock: iconListItemParentBlock } = useBlockContext( parentBlock.clientId ) - if ( ! previousBlock || iconListItemParentBlock?.name === 'stackable/icon-list-item' ) { - willOutdent = true + return parentIconListItemBlock.clientId } - const { hasInnerBlocks: previousHasInnerBlocks, innerBlocks: previousInnerBlocks } = useBlockContext( previousBlock?.clientId ) - if ( previousHasInnerBlocks ) { - // Get the last icon list block of the preceding icon list item. - const lastIconList = previousInnerBlocks[ previousInnerBlocks.length - 1 ] - // Get the last icon list item block. - blockToMerge = lastIconList.innerBlocks[ lastIconList.innerBlocks.length - 1 ] + + const getTrailingId = id => { + const order = getBlockOrder( id ) + + if ( ! order.length ) { + return id + } + + return getTrailingId( order[ order.length - 1 ] ) } - return useCallback( () => { - registry.batch( () => { - if ( willOutdent ) { - outdentListItem() - return - } + /** + * Return the next list item with respect to the given list item. If none, + * return the next list item of the parent list item if it exists. + * + * @param {string} id A list item client ID. + * @return {string?} The client ID of the next list item. + */ + function _getNextId( id ) { + const { nextBlock: next } = select( 'stackable/block-context' ).getBlockContext( id ) + if ( next ) { + return next.clientId + } + const parentListItemId = getParentListItemId( id ) + if ( ! parentListItemId ) { + return + } + return _getNextId( parentListItemId ) + } - const currentAttributes = getBlockAttributes( blockToMerge.clientId ) + /** + * Given a client ID, return the client ID of the list item on the next + * line, regardless of indentation level. + * + * @param {string} id The client ID of the current list item. + * @return {string?} The client ID of the next list item. + */ + function getNextId( id ) { + const order = getBlockOrder( id ) + + // If the list item does not have a nested list, return the next list + // item. + if ( ! order.length ) { + return _getNextId( id ) + } - // eslint-disable-next-line stackable/no-update-block-attributes - updateBlockAttributes( - blockToMerge.clientId, - { text: currentAttributes.text + text } - ) + // Get the first list item in the nested list. + return getBlockOrder( order[ 0 ] )[ 0 ] + } - if ( hasInnerBlocks ) { - const clientIds = innerBlocks.map( block => block.clientId ) - moveBlocksToPosition( clientIds, clientId, blockToMerge.clientId ) + return useCallback( forward => { + function mergeWithNested( clientIdA, clientIdB ) { + registry.batch( () => { + const [ nestedListClientId ] = getBlockOrder( clientIdB ) + // Move any nested list items to the previous list item. + if ( nestedListClientId ) { + moveBlocksToPosition( + getBlockOrder( nestedListClientId ), + nestedListClientId, + getBlockRootClientId( clientIdA ) + ) + } + mergeBlocks( clientIdA, clientIdB ) + } ) + } + + if ( forward ) { + const nextBlockClientId = getNextId( clientId ) + + if ( ! nextBlockClientId ) { + onMerge( forward ) + return } - removeBlock( clientId ) - } ) + if ( getParentListItemId( nextBlockClientId ) ) { + outdentListItem( nextBlockClientId ) + } else { + mergeWithNested( clientId, nextBlockClientId ) + } + } else { + const previousBlockClientId = previousBlock?.clientId + if ( getParentListItemId( clientId ) ) { + // Outdent nested lists. + outdentListItem() + } else if ( previousBlockClientId ) { + const trailingId = getTrailingId( previousBlockClientId ) + mergeWithNested( trailingId, clientId ) + } else { + onMerge( forward ) + } + } }, [ blockContext, clientId ] ) } @@ -198,3 +287,37 @@ export const useOnSplit = ( clientId, attributes ) => { return newBlock }, [ clientId, attributes ] ) } + +export const useCopy = clientId => { + const { + getBlockRootClientId, getBlockName, getBlockAttributes, + } = + useSelect( blockEditorStore ) + + return useRefEffect( node => { + function onCopy( event ) { + // The event propagates through all nested lists, so don't override + // when copying nested list items. + if ( event.clipboardData.getData( '__unstableWrapperBlockName' ) ) { + return + } + + const rootClientId = getBlockRootClientId( clientId ) + event.clipboardData.setData( + '__unstableWrapperBlockName', + getBlockName( rootClientId ) + ) + event.clipboardData.setData( + '__unstableWrapperBlockAttributes', + JSON.stringify( getBlockAttributes( rootClientId ) ) + ) + } + + node.addEventListener( 'copy', onCopy ) + node.addEventListener( 'cut', onCopy ) + return () => { + node.removeEventListener( 'copy', onCopy ) + node.removeEventListener( 'cut', onCopy ) + } + }, [] ) +} diff --git a/src/block/icon-list-new/index.js b/src/block/icon-list-new/index.js index 6f7014f2a..33545dd86 100644 --- a/src/block/icon-list-new/index.js +++ b/src/block/icon-list-new/index.js @@ -12,6 +12,7 @@ import metadata from './block.json' import schema from './schema' import example from './example' import deprecated from './deprecated' +import transforms from './transforms' /** * WordPress dependencies @@ -25,18 +26,13 @@ export const settings = { supports: { anchor: true, spacing: true, + __unstablePasteTextInline: true, + __experimentalSelector: 'ol,ul', + __experimentalOnMerge: true, }, example, deprecated, edit, save, - merge( attributes, attributesToMerge ) { - // Make sure that the selection is always at the end of the text. - // @see https://github.com/WordPress/gutenberg/blob/3da717b8d0ac7d7821fc6d0475695ccf3ae2829f/packages/block-library/src/paragraph/index.js - return { - text: - ( attributes.text || '' ) + - ( attributesToMerge.text || '' ), - } - }, + transforms, } diff --git a/src/block/icon-list-new/schema.js b/src/block/icon-list-new/schema.js index d2dc01f83..78fc18b9c 100644 --- a/src/block/icon-list-new/schema.js +++ b/src/block/icon-list-new/schema.js @@ -105,7 +105,6 @@ export const attributes = ( version = VERSION ) => { ConditionalDisplay.addAttributes( attrObject ) Typography.addAttributes( attrObject, 'ul,ol', { hasTextTag: false, - multilineWrapperTags: [ 'ol', 'ul' ], } ) MarginBottom.addAttributes( attrObject ) diff --git a/src/block/icon-list-new/transforms.js b/src/block/icon-list-new/transforms.js new file mode 100644 index 000000000..d8d8696d7 --- /dev/null +++ b/src/block/icon-list-new/transforms.js @@ -0,0 +1,135 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks' +import { + create, split, toHTMLString, +} from '@wordpress/rich-text' + +/** + * Internal dependencies + */ +import { createListBlockFromDOMElement } from './util' + +function getListContentSchema( { phrasingContentSchema } ) { + const listContentSchema = { + ...phrasingContentSchema, + ul: {}, + ol: { attributes: [ 'type', 'start', 'reversed' ] }, + }; + + // Recursion is needed. + // Possible: ul > li > ul. + // Impossible: ul > ul. + [ 'ul', 'ol' ].forEach( tag => { + listContentSchema[ tag ].children = { + li: { + children: listContentSchema, + }, + } + } ) + + return listContentSchema +} + +function getListContentFlat( blocks ) { + return blocks.flatMap( ( { + name, attributes, innerBlocks = [], + } ) => { + if ( name === 'stackable/icon-list-item' ) { + return [ attributes.text, ...getListContentFlat( innerBlocks ) ] + } + return getListContentFlat( innerBlocks ) + } ) +} + +const transforms = { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ 'core/paragraph', 'core/heading', 'stackable/text', 'stackable/heading', 'stackable/subtitle' ], + transform: blockAttributes => { + let childBlocks = [] + if ( blockAttributes.length > 1 ) { + childBlocks = blockAttributes.map( ( { content } ) => { + return createBlock( 'stackable/icon-list-item', { text: content } ) + } ) + } else if ( blockAttributes.length === 1 ) { + const value = create( { + html: blockAttributes[ 0 ].content, + } ) + childBlocks = split( value, '\n' ).map( result => { + return createBlock( 'stackable/icon-list-item', { + text: toHTMLString( { value: result } ), + } ) + } ) + } + return createBlock( + 'stackable/icon-list-new', + { + anchor: blockAttributes.anchor, + }, + childBlocks + ) + }, + }, + { + type: 'raw', + selector: 'ol,ul', + schema: args => ( { + ol: getListContentSchema( args ).ol, + ul: getListContentSchema( args ).ul, + } ), + transform: createListBlockFromDOMElement, + }, + ...[ '*', '-' ].map( prefix => ( { + type: 'prefix', + prefix, + transform( content ) { + return createBlock( 'stackable/icon-list-new', {}, [ + createBlock( 'stackable/icon-list-item', { text: content } ), + ] ) + }, + } ) ), + ...[ '1.', '1)' ].map( prefix => ( { + type: 'prefix', + prefix, + transform( content ) { + return createBlock( + 'stackable/icon-list-new', + { + ordered: true, + }, + [ createBlock( 'stackable/icon-list-item', { text: content } ) ] + ) + }, + } ) ), + ], + to: [ + ...[ 'core/paragraph', 'core/heading' ].map( block => ( { + type: 'block', + blocks: [ block ], + transform: ( _attributes, childBlocks ) => { + return getListContentFlat( childBlocks ).map( content => + createBlock( block, { + content, + } ) + ) + }, + } ) ), + ...[ 'stackable/text', 'stackable/heading' ].map( block => ( { + type: 'block', + blocks: [ block ], + transform: ( _attributes, childBlocks ) => { + return getListContentFlat( childBlocks ).map( content => + createBlock( block, { + text: content, + } ) + ) + }, + } ) ), + ], +} + +export default transforms diff --git a/src/block/icon-list-new/util.js b/src/block/icon-list-new/util.js index 345bd7886..fbff9a9ed 100644 --- a/src/block/icon-list-new/util.js +++ b/src/block/icon-list-new/util.js @@ -1,24 +1,14 @@ -/** - * External dependencies - */ -import { faGetSVGIcon, createElementFromHTMLString } from '~stackable/util' -import { kebabCase } from 'lodash' - /** * WordPress dependencies */ -import { RichTextShortcut } from '@wordpress/block-editor' import { createElement } from '@wordpress/element' -/* eslint-disable @wordpress/no-unsafe-wp-apis */ -import { - __unstableIndentListItems as indentListItems, - __unstableOutdentListItems as outdentListItems, -} from '@wordpress/rich-text' -/* eslint-enable @wordpress/no-unsafe-wp-apis */ import { __, _x } from '@wordpress/i18n' +import { createBlock } from '@wordpress/blocks' import { forwardRef } from 'react' +import { i18n } from 'stackable' + // The default icon list SVG. export const DEFAULT_SVG = '' @@ -40,119 +30,96 @@ export const WithForwardRefComponent = forwardRef( ( props, ref ) => { return { props.children } } ) - /** - * Convert SVG tag to base64 string - * - * @param {string} svgTag - * @param {string} color - * @param {Object} styles additional styles - * - * @return {string} base64 string + * WordPress dependencies */ -export const convertSVGStringToBase64 = ( svgTag = '', color = '', styles = {} ) => { - let svgTagString = svgTag - // If no SVG given, use the default SVG. - if ( ! svgTag ) { - svgTagString = DEFAULT_SVG - } +const listStyleTypeOptions = { + d: { + label: __( 'Number', i18n ), + value: 'decimal', + }, + D: { + label: __( 'Padded Number', i18n ), + value: 'decimal-leading-zero', + }, + i: { + label: __( 'Lowercase Roman', i18n ), + value: 'lower-roman', + }, + I: { + label: __( 'Uppercase Roman', i18n ), + value: 'upper-roman', + }, + a: { + label: __( 'Lowercase Letters', i18n ), + value: 'lower-alpha', + }, + A: { + label: __( 'Uppercase Letters', i18n ), + value: 'upper-alpha', + }, +} - if ( typeof svgTag === 'string' && svgTag.split( '-' ).length === 2 ) { - const [ prefix, iconName ] = svgTag.split( '-' ) - svgTagString = faGetSVGIcon( prefix, iconName ) +export function createListBlockFromDOMElement( listElement ) { + const type = listElement.getAttribute( 'type' ) + const listAttributes = { + ordered: 'OL' === listElement.tagName, + anchor: listElement.id === '' ? undefined : listElement.id, + listType: type && listStyleTypeOptions[ type ] ? listStyleTypeOptions[ type ] : undefined, } - const svgEl = createElementFromHTMLString( svgTagString ) - if ( svgEl ) { - const svgChildElements = svgEl.querySelectorAll( '*' ) - - if ( color ) { - let _color = color - if ( color.match( /#([\d\w]{6})/g ) ) { - _color = color.match( /#([\d\w]{6})/g )[ 0 ] - } else if ( color.match( /var\((.*)?--[\w\d-_]+/g ) ) { - const colorVariable = color.match( /--[\w\d-_]+/g )[ 0 ] - try { - // Try and get the actual value, this can possibly get an error due to stylesheet access security. - _color = window.getComputedStyle( document.documentElement ).getPropertyValue( colorVariable ) || color - } catch ( err ) { - _color = color - } + const innerBlocks = Array.from( listElement.children ).map( + listItem => { + const children = Array.from( listItem.childNodes ).filter( + node => + node.nodeType !== node.TEXT_NODE || + node.textContent.trim().length !== 0 + ) + children.reverse() + const [ nestedList, ...nodes ] = children + + const hasNestedList = + nestedList?.tagName === 'UL' || nestedList?.tagName === 'OL' + if ( ! hasNestedList ) { + return createBlock( 'stackable/icon-list-item', { + text: listItem.innerHTML, + } ) } - - svgChildElements.forEach( child => { - if ( child && ! [ 'DEFS', 'TITLE', 'DESC' ].includes( child.tagName ) ) { - child.setAttribute( 'fill', _color ) - child.setAttribute( 'stroke', _color ) - child.style.fill = _color - child.style.stroke = _color + const htmlNodes = nodes.map( node => { + if ( node.nodeType === node.TEXT_NODE ) { + return node.textContent } + return node.outerHTML } ) - const willEnqueueStyles = Object.keys( styles ).map( key => typeof styles[ key ] !== 'undefined' && styles[ key ] !== '' ? `${ kebabCase( key ) }: ${ styles[ key ] } !important;` : '' ).join( '' ) - svgEl.setAttribute( 'style', `fill: ${ _color } !important; color: ${ _color } !important;` + willEnqueueStyles ) + htmlNodes.reverse() + const childAttributes = { + text: htmlNodes.join( '' ).trim(), + } + const childInnerBlocks = [ + createListBlockFromDOMElement( nestedList ), + ] + return createBlock( + 'stackable/icon-list-item', + childAttributes, + childInnerBlocks + ) } + ) - /** - * Use XMLSerializer to create XML string from DOM Element - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLSerializer - */ - const serializedString = new XMLSerializer().serializeToString( svgEl ) //eslint-disable-line no-undef + return createBlock( 'stackable/icon-list-new', listAttributes, innerBlocks ) +} - return window.btoa( serializedString ) +export function migrateTypeToInlineStyle( attributes ) { + const { type } = attributes + + if ( type && listStyleTypeOptions[ type ] ) { + return { + ...attributes, + listType: listStyleTypeOptions[ type ], + } } -} -/** - * Create a toolbar control - * for the icon list block. - * - * @param {{ isSelected, tagName }} options - * @return {Function} function which will be used as render prop. - */ -export const createIconListControls = ( options = {} ) => { - const { - isSelected, - tagName, - } = options - - return ( { - value, onChange, - } ) => isSelected && ( - <> - { - onChange( outdentListItems( value ) ) - } } - /> - { - onChange( - indentListItems( value, { type: tagName } ) - ) - } } - /> - { - onChange( - indentListItems( value, { type: tagName } ) - ) - } } - /> - { - onChange( outdentListItems( value ) ) - } } - /> - - ) + return attributes } +