Skip to content

Commit

Permalink
Add checklist block
Browse files Browse the repository at this point in the history
  • Loading branch information
ellatrix committed Jun 30, 2024
1 parent 717903f commit e88a2c3
Show file tree
Hide file tree
Showing 23 changed files with 1,097 additions and 0 deletions.
54 changes: 54 additions & 0 deletions src/blocks/list-item/block.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
122 changes: 122 additions & 0 deletions src/blocks/list-item/edit.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ToolbarButton
icon={isRTL() ? formatOutdentRTL : formatOutdent}
title={__('Outdent')}
describedBy={__('Outdent list item')}
disabled={!canOutdent}
onClick={() => outdentListItem()}
/>
<ToolbarButton
icon={isRTL() ? formatIndentRTL : formatIndent}
title={__('Indent')}
describedBy={__('Indent list item')}
isDisabled={!canIndent}
onClick={() => 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 (
<>
<li {...innerBlocksProps}>
<RichText
ref={useMergeRefs([useEnterRef, useSpaceRef])}
identifier="content"
tagName="div"
onChange={(nextContent) =>
setAttributes({ content: nextContent })
}
value={content}
aria-label={__('List text')}
placeholder={placeholder || __('List')}
onMerge={onMerge}
/>
<CheckboxControl
checked={attributes.completed === 'done'}
indeterminate={attributes.completed === 'progress'}
onChange={() => {
if (!attributes.completed) {
setAttributes({ completed: 'progress' });
} else if (attributes.completed === 'progress') {
setAttributes({ completed: 'done' });
} else {
setAttributes({ completed: false });
}
}}
/>
{innerBlocksProps.children}
</li>
<BlockControls group="block">
<IndentUI clientId={clientId} />
</BlockControls>
</>
);
}
6 changes: 6 additions & 0 deletions src/blocks/list-item/hooks/index.js
Original file line number Diff line number Diff line change
@@ -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';
88 changes: 88 additions & 0 deletions src/blocks/list-item/hooks/use-enter.js
Original file line number Diff line number Diff line change
@@ -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);
};
}, []);
}
67 changes: 67 additions & 0 deletions src/blocks/list-item/hooks/use-indent-list-item.js
Original file line number Diff line number Diff line change
@@ -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]);

Check warning on line 66 in src/blocks/list-item/hooks/use-indent-list-item.js

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has missing dependencies: 'getBlock', 'getMultiSelectedBlockClientIds', 'getPreviousBlockClientId', 'getSelectionEnd', 'getSelectionStart', 'hasMultiSelection', 'multiSelect', 'replaceBlocks', and 'selectionChange'. Either include them or remove the dependency array
}
Loading

0 comments on commit e88a2c3

Please sign in to comment.