diff --git a/src/blocks/list-item/block.json b/src/blocks/list-item/block.json new file mode 100644 index 0000000..2da40ce --- /dev/null +++ b/src/blocks/list-item/block.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/checklist-item", + "title": "List item", + "category": "text", + "parent": [ "core/checklist" ], + "allowedBlocks": [ "core/checklist" ], + "description": "Create a list item.", + "textdomain": "default", + "attributes": { + "placeholder": { + "type": "string" + }, + "content": { + "type": "rich-text", + "source": "rich-text", + "selector": "li", + "__experimentalRole": "content" + }, + "completed": { + "type": "string" + } + }, + "supports": { + "className": false, + "__experimentalSelector": ".wp-block-list > li", + "splitting": true, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "interactivity": { + "clientNavigation": true + } + } +} diff --git a/src/blocks/list-item/edit.jsx b/src/blocks/list-item/edit.jsx new file mode 100644 index 0000000..e75de8a --- /dev/null +++ b/src/blocks/list-item/edit.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +/** + * WordPress dependencies + */ +import { + RichText, + useBlockProps, + useInnerBlocksProps, + BlockControls, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { isRTL, __ } from '@wordpress/i18n'; +import { ToolbarButton, CheckboxControl } from '@wordpress/components'; +import { + formatOutdent, + formatOutdentRTL, + formatIndentRTL, + formatIndent, +} from '@wordpress/icons'; +import { useMergeRefs } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + useEnter, + useSpace, + useIndentListItem, + useOutdentListItem, + useMerge, +} from './hooks'; + +export function IndentUI({ clientId }) { + const indentListItem = useIndentListItem(clientId); + const outdentListItem = useOutdentListItem(); + const { canIndent, canOutdent } = useSelect( + (select) => { + const { getBlockIndex, getBlockRootClientId, getBlockName } = + select(blockEditorStore); + return { + canIndent: getBlockIndex(clientId) > 0, + canOutdent: + getBlockName( + getBlockRootClientId(getBlockRootClientId(clientId)) + ) === 'core/checklist-item', + }; + }, + [clientId] + ); + + return ( + <> + outdentListItem()} + /> + indentListItem()} + /> + + ); +} + +export default function ListItemEdit({ + attributes, + setAttributes, + clientId, + mergeBlocks, +}) { + const { placeholder, content } = attributes; + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps(blockProps, { + renderAppender: false, + __unstableDisableDropZone: true, + }); + const useEnterRef = useEnter({ content, clientId }); + const useSpaceRef = useSpace(clientId); + const onMerge = useMerge(clientId, mergeBlocks); + return ( + <> +
  • + + setAttributes({ content: nextContent }) + } + value={content} + aria-label={__('List text')} + placeholder={placeholder || __('List')} + onMerge={onMerge} + /> + { + if (!attributes.completed) { + setAttributes({ completed: 'progress' }); + } else if (attributes.completed === 'progress') { + setAttributes({ completed: 'done' }); + } else { + setAttributes({ completed: false }); + } + }} + /> + {innerBlocksProps.children} +
  • + + + + + ); +} diff --git a/src/blocks/list-item/hooks/index.js b/src/blocks/list-item/hooks/index.js new file mode 100644 index 0000000..1687adb --- /dev/null +++ b/src/blocks/list-item/hooks/index.js @@ -0,0 +1,6 @@ +export { default as useOutdentListItem } from './use-outdent-list-item'; +export { default as useIndentListItem } from './use-indent-list-item'; +export { default as useEnter } from './use-enter'; +export { default as useSpace } from './use-space'; +export { default as useSplit } from './use-split'; +export { default as useMerge } from './use-merge'; diff --git a/src/blocks/list-item/hooks/use-enter.js b/src/blocks/list-item/hooks/use-enter.js new file mode 100644 index 0000000..de87037 --- /dev/null +++ b/src/blocks/list-item/hooks/use-enter.js @@ -0,0 +1,88 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getDefaultBlockName, + cloneBlock, +} from '@wordpress/blocks'; +import { useRef } from '@wordpress/element'; +import { useRefEffect } from '@wordpress/compose'; +import { ENTER } from '@wordpress/keycodes'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import useOutdentListItem from './use-outdent-list-item'; + +export default function useEnter(props) { + const { replaceBlocks, selectionChange } = useDispatch(blockEditorStore); + const { getBlock, getBlockRootClientId, getBlockIndex, getBlockName } = + useSelect(blockEditorStore); + const propsRef = useRef(props); + propsRef.current = props; + const outdentListItem = useOutdentListItem(); + return useRefEffect((element) => { + function onKeyDown(event) { + if (event.defaultPrevented || event.keyCode !== ENTER) { + return; + } + const { content, clientId } = propsRef.current; + if (content.length) { + return; + } + event.preventDefault(); + const canOutdent = + getBlockName( + getBlockRootClientId( + getBlockRootClientId(propsRef.current.clientId) + ) + ) === 'core/checklist-item'; + if (canOutdent) { + outdentListItem(); + return; + } + // Here we are in top level list so we need to split. + const topParentListBlock = getBlock(getBlockRootClientId(clientId)); + const blockIndex = getBlockIndex(clientId); + const head = cloneBlock({ + ...topParentListBlock, + innerBlocks: topParentListBlock.innerBlocks.slice( + 0, + blockIndex + ), + }); + const middle = createBlock(getDefaultBlockName()); + // Last list item might contain a `list` block innerBlock + // In that case append remaining innerBlocks blocks. + const after = [ + ...(topParentListBlock.innerBlocks[blockIndex].innerBlocks[0] + ?.innerBlocks || []), + ...topParentListBlock.innerBlocks.slice(blockIndex + 1), + ]; + const tail = after.length + ? [ + cloneBlock({ + ...topParentListBlock, + innerBlocks: after, + }), + ] + : []; + replaceBlocks( + topParentListBlock.clientId, + [head, middle, ...tail], + 1 + ); + // We manually change the selection here because we are replacing + // a different block than the selected one. + selectionChange(middle.clientId); + } + + element.addEventListener('keydown', onKeyDown); + return () => { + element.removeEventListener('keydown', onKeyDown); + }; + }, []); +} diff --git a/src/blocks/list-item/hooks/use-indent-list-item.js b/src/blocks/list-item/hooks/use-indent-list-item.js new file mode 100644 index 0000000..7807924 --- /dev/null +++ b/src/blocks/list-item/hooks/use-indent-list-item.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { createBlock, cloneBlock } from '@wordpress/blocks'; + +export default function useIndentListItem(clientId) { + const { replaceBlocks, selectionChange, multiSelect } = + useDispatch(blockEditorStore); + const { + getBlock, + getPreviousBlockClientId, + getSelectionStart, + getSelectionEnd, + hasMultiSelection, + getMultiSelectedBlockClientIds, + } = useSelect(blockEditorStore); + return useCallback(() => { + const _hasMultiSelection = hasMultiSelection(); + const clientIds = _hasMultiSelection + ? getMultiSelectedBlockClientIds() + : [clientId]; + const clonedBlocks = clientIds.map((_clientId) => + cloneBlock(getBlock(_clientId)) + ); + const previousSiblingId = getPreviousBlockClientId(clientId); + const newListItem = cloneBlock(getBlock(previousSiblingId)); + // If the sibling has no innerBlocks, create a new `list` block. + if (!newListItem.innerBlocks?.length) { + newListItem.innerBlocks = [createBlock('core/checklist')]; + } + // A list item usually has one `list`, but it's possible to have + // more. So we need to preserve the previous `list` blocks and + // merge the new blocks to the last `list`. + newListItem.innerBlocks[ + newListItem.innerBlocks.length - 1 + ].innerBlocks.push(...clonedBlocks); + + // We get the selection start/end here, because when + // we replace blocks, the selection is updated too. + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + // Replace the previous sibling of the block being indented and the indented blocks, + // with a new block whose attributes are equal to the ones of the previous sibling and + // whose descendants are the children of the previous sibling, followed by the indented blocks. + replaceBlocks([previousSiblingId, ...clientIds], [newListItem]); + if (!_hasMultiSelection) { + selectionChange( + clonedBlocks[0].clientId, + selectionEnd.attributeKey, + selectionEnd.clientId === selectionStart.clientId + ? selectionStart.offset + : selectionEnd.offset, + selectionEnd.offset + ); + } else { + multiSelect( + clonedBlocks[0].clientId, + clonedBlocks[clonedBlocks.length - 1].clientId + ); + } + + return true; + }, [clientId]); +} diff --git a/src/blocks/list-item/hooks/use-merge.js b/src/blocks/list-item/hooks/use-merge.js new file mode 100644 index 0000000..bc9ebd4 --- /dev/null +++ b/src/blocks/list-item/hooks/use-merge.js @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import { useRegistry, useDispatch, useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import useOutdentListItem from './use-outdent-list-item'; + +export default function useMerge(clientId, onMerge) { + const registry = useRegistry(); + const { + getPreviousBlockClientId, + getNextBlockClientId, + getBlockOrder, + getBlockRootClientId, + getBlockName, + } = useSelect(blockEditorStore); + const { mergeBlocks, moveBlocksToPosition } = useDispatch(blockEditorStore); + const outdentListItem = useOutdentListItem(); + + function getTrailingId(id) { + const order = getBlockOrder(id); + + if (!order.length) { + return id; + } + + return getTrailingId(order[order.length - 1]); + } + + function getParentListItemId(id) { + const listId = getBlockRootClientId(id); + const parentListItemId = getBlockRootClientId(listId); + if (!parentListItemId) { + return; + } + if (getBlockName(parentListItemId) !== 'core/checklist-item') { + return; + } + return parentListItemId; + } + + /** + * 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 next = getNextBlockClientId(id); + if (next) { + return next; + } + const parentListItemId = getParentListItemId(id); + if (!parentListItemId) { + return; + } + return _getNextId(parentListItemId); + } + + /** + * 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); + } + + // Get the first list item in the nested list. + return getBlockOrder(order[0])[0]; + } + + return (forward) => { + function mergeWithNested(clientIdA, clientIdB) { + registry.batch(() => { + // When merging a sub list item with a higher next list item, we + // also need to move any nested list items. Check if there's a + // listed list, and append its nested list items to the current + // list. + const [nestedListClientId] = getBlockOrder(clientIdB); + if (nestedListClientId) { + moveBlocksToPosition( + getBlockOrder(nestedListClientId), + nestedListClientId, + getBlockRootClientId(clientIdA) + ); + } + mergeBlocks(clientIdA, clientIdB); + }); + } + + if (forward) { + const nextBlockClientId = getNextId(clientId); + + if (!nextBlockClientId) { + onMerge(forward); + return; + } + + if (getParentListItemId(nextBlockClientId)) { + outdentListItem(nextBlockClientId); + } else { + mergeWithNested(clientId, nextBlockClientId); + } + } else { + // Merging is only done from the top level. For lowel levels, the + // list item is outdented instead. + const previousBlockClientId = getPreviousBlockClientId(clientId); + if (getParentListItemId(clientId)) { + outdentListItem(clientId); + } else if (previousBlockClientId) { + const trailingId = getTrailingId(previousBlockClientId); + mergeWithNested(trailingId, clientId); + } else { + onMerge(forward); + } + } + }; +} diff --git a/src/blocks/list-item/hooks/use-outdent-list-item.js b/src/blocks/list-item/hooks/use-outdent-list-item.js new file mode 100644 index 0000000..95afd0d --- /dev/null +++ b/src/blocks/list-item/hooks/use-outdent-list-item.js @@ -0,0 +1,107 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { cloneBlock } from '@wordpress/blocks'; + +export default function useOutdentListItem() { + const registry = useRegistry(); + const { + moveBlocksToPosition, + removeBlock, + insertBlock, + updateBlockListSettings, + } = useDispatch(blockEditorStore); + const { + getBlockRootClientId, + getBlockName, + getBlockOrder, + getBlockIndex, + getSelectedBlockClientIds, + getBlock, + getBlockListSettings, + } = useSelect(blockEditorStore); + + function getParentListItemId(id) { + const listId = getBlockRootClientId(id); + const parentListItemId = getBlockRootClientId(listId); + if (!parentListItemId) { + return; + } + if (getBlockName(parentListItemId) !== 'core/checklist-item') { + return; + } + return parentListItemId; + } + + return useCallback((clientIds = getSelectedBlockClientIds()) => { + if (!Array.isArray(clientIds)) { + clientIds = [clientIds]; + } + + if (!clientIds.length) { + return; + } + + const firstClientId = clientIds[0]; + + // Can't outdent if it's not a list item. + if (getBlockName(firstClientId) !== 'core/checklist-item') { + return; + } + + const parentListItemId = getParentListItemId(firstClientId); + + // Can't outdent if it's at the top level. + if (!parentListItemId) { + return; + } + + const parentListId = getBlockRootClientId(firstClientId); + const lastClientId = clientIds[clientIds.length - 1]; + const order = getBlockOrder(parentListId); + const followingListItems = order.slice(getBlockIndex(lastClientId) + 1); + + registry.batch(() => { + if (followingListItems.length) { + let nestedListId = getBlockOrder(firstClientId)[0]; + + if (!nestedListId) { + const nestedListBlock = cloneBlock( + getBlock(parentListId), + {}, + [] + ); + nestedListId = nestedListBlock.clientId; + insertBlock(nestedListBlock, 0, firstClientId, false); + // Immediately update the block list settings, otherwise + // blocks can't be moved here due to canInsert checks. + updateBlockListSettings( + nestedListId, + getBlockListSettings(parentListId) + ); + } + + moveBlocksToPosition( + followingListItems, + parentListId, + nestedListId + ); + } + moveBlocksToPosition( + clientIds, + parentListId, + getBlockRootClientId(parentListItemId), + getBlockIndex(parentListItemId) + 1 + ); + if (!getBlockOrder(parentListId).length) { + const shouldSelectParent = false; + removeBlock(parentListId, shouldSelectParent); + } + }); + + return true; + }, []); +} diff --git a/src/blocks/list-item/hooks/use-space.js b/src/blocks/list-item/hooks/use-space.js new file mode 100644 index 0000000..6d687a0 --- /dev/null +++ b/src/blocks/list-item/hooks/use-space.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { useRefEffect } from '@wordpress/compose'; +import { SPACE, TAB } from '@wordpress/keycodes'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import useIndentListItem from './use-indent-list-item'; +import useOutdentListItem from './use-outdent-list-item'; + +export default function useSpace(clientId) { + const { getSelectionStart, getSelectionEnd, getBlockIndex } = + useSelect(blockEditorStore); + const indentListItem = useIndentListItem(clientId); + const outdentListItem = useOutdentListItem(); + + return useRefEffect( + (element) => { + function onKeyDown(event) { + const { keyCode, shiftKey, altKey, metaKey, ctrlKey } = event; + + if ( + event.defaultPrevented || + (keyCode !== SPACE && keyCode !== TAB) || + // Only override when no modifiers are pressed. + altKey || + metaKey || + ctrlKey + ) { + return; + } + + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + if (selectionStart.offset === 0 && selectionEnd.offset === 0) { + if (shiftKey) { + // Note that backspace behaviour in defined in onMerge. + if (keyCode === TAB) { + if (outdentListItem()) { + event.preventDefault(); + } + } + } else if (getBlockIndex(clientId) !== 0) { + if (indentListItem()) { + event.preventDefault(); + } + } + } + } + + element.addEventListener('keydown', onKeyDown); + return () => { + element.removeEventListener('keydown', onKeyDown); + }; + }, + [clientId, indentListItem] + ); +} diff --git a/src/blocks/list-item/hooks/use-split.js b/src/blocks/list-item/hooks/use-split.js new file mode 100644 index 0000000..a1f2f3f --- /dev/null +++ b/src/blocks/list-item/hooks/use-split.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { useCallback, useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { cloneBlock, createBlock } from '@wordpress/blocks'; + +export default function useSplit(clientId) { + // We can not rely on the isAfterOriginal parameter of the callback, + // because if the value after the split is empty isAfterOriginal is false + // while the value is in fact after the original. So to avoid that issue we use + // a flag where the first execution of the callback is false (it is the before value) + // and the second execution is true, it is the after value. + const isAfter = useRef(false); + const { getBlock } = useSelect(blockEditorStore); + return useCallback( + (value) => { + const block = getBlock(clientId); + if (isAfter.current) { + return cloneBlock(block, { + content: value, + }); + } + isAfter.current = true; + return createBlock(block.name, { + ...block.attributes, + content: value, + }); + }, + [clientId, getBlock] + ); +} diff --git a/src/blocks/list-item/index.js b/src/blocks/list-item/index.js new file mode 100644 index 0000000..d70306c --- /dev/null +++ b/src/blocks/list-item/index.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { listItem as icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + icon, + edit, + save, + merge(attributes, attributesToMerge) { + return { + ...attributes, + content: attributes.content + attributesToMerge.content, + }; + }, + transforms, +}; + +export const init = () => initBlock({ name, metadata, settings }); diff --git a/src/blocks/list-item/init.js b/src/blocks/list-item/init.js new file mode 100644 index 0000000..79f0492 --- /dev/null +++ b/src/blocks/list-item/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/src/blocks/list-item/save.jsx b/src/blocks/list-item/save.jsx new file mode 100644 index 0000000..ce49ef4 --- /dev/null +++ b/src/blocks/list-item/save.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +/** + * WordPress dependencies + */ +import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor'; + +export default function save({ attributes }) { + return ( +
  • + + +
  • + ); +} diff --git a/src/blocks/list-item/transforms.js b/src/blocks/list-item/transforms.js new file mode 100644 index 0000000..3efb6e7 --- /dev/null +++ b/src/blocks/list-item/transforms.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { createBlock, cloneBlock } from '@wordpress/blocks'; + +const transforms = { + to: [ + { + type: 'block', + blocks: ['core/paragraph'], + transform: (attributes, innerBlocks = []) => [ + createBlock('core/paragraph', attributes), + ...innerBlocks.map((block) => cloneBlock(block)), + ], + }, + ], +}; + +export default transforms; diff --git a/src/blocks/list/block.json b/src/blocks/list/block.json new file mode 100644 index 0000000..9a4052f --- /dev/null +++ b/src/blocks/list/block.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/checklist", + "title": "Checklist", + "category": "text", + "allowedBlocks": [ "core/checklist-item" ], + "description": "Create a bulleted or numbered list.", + "keywords": [ "task", "todo" ], + "textdomain": "default", + "attributes": { + "placeholder": { + "type": "string" + } + }, + "supports": { + "anchor": true, + "html": false, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "__unstablePasteTextInline": true, + "__experimentalOnMerge": true, + "__experimentalSlashInserter": true, + "interactivity": { + "clientNavigation": true + } + }, + "editorStyle": "wp-block-list-editor", + "style": "wp-block-list" +} diff --git a/src/blocks/list/edit.jsx b/src/blocks/list/edit.jsx new file mode 100644 index 0000000..95eb68a --- /dev/null +++ b/src/blocks/list/edit.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +/** + * WordPress dependencies + */ +import { + BlockControls, + useBlockProps, + useInnerBlocksProps, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { ToolbarButton } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { isRTL, __ } from '@wordpress/i18n'; +import { formatOutdent, formatOutdentRTL } from '@wordpress/icons'; +import { createBlock } from '@wordpress/blocks'; +import { useCallback } from '@wordpress/element'; + +const DEFAULT_BLOCK = { + name: 'core/checklist-item', +}; +const TEMPLATE = [['core/checklist-item']]; + +function useOutdentList(clientId) { + const { replaceBlocks, selectionChange } = useDispatch(blockEditorStore); + const { getBlockRootClientId, getBlockAttributes, getBlock } = + useSelect(blockEditorStore); + + return useCallback(() => { + const parentBlockId = getBlockRootClientId(clientId); + const parentBlockAttributes = getBlockAttributes(parentBlockId); + // Create a new parent block without the inner blocks. + const newParentBlock = createBlock( + 'core/checklist-item', + parentBlockAttributes + ); + const { innerBlocks } = getBlock(clientId); + // Replace the parent block with a new parent block without inner blocks, + // and make the inner blocks siblings of the parent. + replaceBlocks([parentBlockId], [newParentBlock, ...innerBlocks]); + // Select the last child of the list being outdent. + selectionChange(innerBlocks[innerBlocks.length - 1].clientId); + }, [clientId]); +} + +function IndentUI({ clientId }) { + const outdentList = useOutdentList(clientId); + const canOutdent = useSelect( + (select) => { + const { getBlockRootClientId, getBlockName } = + select(blockEditorStore); + return ( + getBlockName(getBlockRootClientId(clientId)) === + 'core/checklist-item' + ); + }, + [clientId] + ); + return ( + <> + + + ); +} + +export default function Edit({ clientId }) { + const blockProps = useBlockProps(); + + const innerBlocksProps = useInnerBlocksProps(blockProps, { + defaultBlock: DEFAULT_BLOCK, + directInsert: true, + template: TEMPLATE, + templateLock: false, + templateInsertUpdatesSelection: true, + __experimentalCaptureToolbars: true, + }); + + const controls = ( + + + + ); + + return ( + <> +
      + {controls} + + ); +} diff --git a/src/blocks/list/index.js b/src/blocks/list/index.js new file mode 100644 index 0000000..c46fafb --- /dev/null +++ b/src/blocks/list/index.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { published as icon } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import transforms from './transforms'; + +const { name } = metadata; + +export { metadata, name }; + +const settings = { + icon, + example: { + innerBlocks: [ + { + name: 'core/checklist-item', + attributes: { content: __('Alice.') }, + }, + { + name: 'core/checklist-item', + attributes: { content: __('The White Rabbit.') }, + }, + { + name: 'core/checklist-item', + attributes: { content: __('The Cheshire Cat.') }, + }, + { + name: 'core/checklist-item', + attributes: { content: __('The Mad Hatter.') }, + }, + { + name: 'core/checklist-item', + attributes: { content: __('The Queen of Hearts.') }, + }, + ], + }, + transforms, + edit, + save, +}; + +export { settings }; + +export const init = () => initBlock({ name, metadata, settings }); diff --git a/src/blocks/list/init.js b/src/blocks/list/init.js new file mode 100644 index 0000000..79f0492 --- /dev/null +++ b/src/blocks/list/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/src/blocks/list/save.jsx b/src/blocks/list/save.jsx new file mode 100644 index 0000000..a062c80 --- /dev/null +++ b/src/blocks/list/save.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +/** + * WordPress dependencies + */ +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; + +export default function save() { + return ( +
        + +
      + ); +} diff --git a/src/blocks/list/style.scss b/src/blocks/list/style.scss new file mode 100644 index 0000000..11f7c48 --- /dev/null +++ b/src/blocks/list/style.scss @@ -0,0 +1,8 @@ +ol, +ul { + box-sizing: border-box; +} + +:root :where(ul.has-background, ol.has-background) { + padding: $block-bg-padding--v $block-bg-padding--h; +} diff --git a/src/blocks/list/transforms.js b/src/blocks/list/transforms.js new file mode 100644 index 0000000..8c8604f --- /dev/null +++ b/src/blocks/list/transforms.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { create, split, toHTMLString } from '@wordpress/rich-text'; + +function getListContentFlat(blocks) { + return blocks.flatMap(({ name, attributes, innerBlocks = [] }) => { + if (name === 'core/checklist-item') { + return [attributes.content, ...getListContentFlat(innerBlocks)]; + } + return getListContentFlat(innerBlocks); + }); +} + +function createMap(listName, listItemName) { + return function mapToList(attributes, innerBlocks) { + return createBlock( + listName, + attributes, + innerBlocks.map((listItemBlock) => + createBlock( + listItemName, + listItemBlock.attributes, + listItemBlock.innerBlocks.map((listBlock) => + mapToList(listBlock.attributes, listBlock.innerBlocks) + ) + ) + ) + ); + }; +} + +const transforms = { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: ['core/paragraph', 'core/heading'], + transform: (blockAttributes) => { + let childBlocks = []; + if (blockAttributes.length > 1) { + childBlocks = blockAttributes.map(({ content }) => { + return createBlock('core/checklist-item', { content }); + }); + } else if (blockAttributes.length === 1) { + const value = create({ + html: blockAttributes[0].content, + }); + childBlocks = split(value, '\n').map((result) => { + return createBlock('core/checklist-item', { + content: toHTMLString({ value: result }), + }); + }); + } + return createBlock( + 'core/checklist', + { + anchor: blockAttributes.anchor, + }, + childBlocks + ); + }, + }, + { + type: 'block', + blocks: ['core/list'], + transform: createMap('core/checklist', 'core/checklist-item'), + }, + ], + to: [ + ...['core/paragraph', 'core/heading'].map((block) => ({ + type: 'block', + blocks: [block], + transform: (_attributes, childBlocks) => { + return getListContentFlat(childBlocks).map((content) => + createBlock(block, { + content, + }) + ); + }, + })), + { + type: 'block', + blocks: ['core/list'], + transform: createMap('core/list', 'core/list-item'), + }, + ], +}; + +export default transforms; diff --git a/src/blocks/utils/init-block.js b/src/blocks/utils/init-block.js new file mode 100644 index 0000000..0139f17 --- /dev/null +++ b/src/blocks/utils/init-block.js @@ -0,0 +1,9 @@ +import { registerBlockType } from '@wordpress/blocks'; + +export default function initBlock(block) { + if (!block) { + return; + } + const { metadata, settings, name } = block; + return registerBlockType({ name, ...metadata }, settings); +} diff --git a/src/content.css b/src/content.css index 90c863b..2e2c01c 100644 --- a/src/content.css +++ b/src/content.css @@ -4,4 +4,26 @@ body { font-family: Hoefler Text; font-size: 20px; padding: 1px 1em; +} + +[data-type="core/checklist"] { + /* padding-left: 0; */ +} + +[data-type="core/checklist-item"] { + margin-top: 5px; +} + +[data-type="core/checklist-item"] .components-checkbox-control__input[type=checkbox]:indeterminate { + filter: grayscale(1); +} + +[data-type="core/checklist-item"] .components-checkbox-control__input[type=checkbox]:indeterminate + svg { + filter: grayscale(1); +} + +[data-type="core/checklist-item"] > .components-checkbox-control { + position: absolute; + transform: translate(-100%); + top: 0; } \ No newline at end of file diff --git a/src/index.js b/src/index.js index 95ba228..8229749 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,8 @@ import '@wordpress/components/build-style/style.css'; import './app.css'; import './block-types/auto-generated.js'; +import './blocks/list/init'; +import './blocks/list-item/init'; setDefaultBlockName('core/paragraph');