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
}
+