@wordpress/block-library
Version:
Block library for the WordPress editor.
231 lines (204 loc) • 9.55 kB
JavaScript
import { createElement, Fragment } from "@wordpress/element";
/**
* External dependencies
*/
import { isEqual } from 'lodash';
/**
* WordPress dependencies
*/
import { BlockControls, BlockIcon, InspectorControls, store as blockEditorStore, useBlockProps } from '@wordpress/block-editor';
import { createBlock } from '@wordpress/blocks';
import { PanelBody, Placeholder, ToggleControl, ToolbarButton, ToolbarGroup } from '@wordpress/components';
import { useDisabled } from '@wordpress/compose';
import { useDispatch, useSelect } from '@wordpress/data';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
import { renderToString, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import icon from './icon';
import TableOfContentsList from './list';
import { linearToNestedHeadingList } from './utils';
/** @typedef {import('./utils').HeadingData} HeadingData */
/**
* Table of Contents block edit component.
*
* @param {Object} props The props.
* @param {Object} props.attributes The block attributes.
* @param {HeadingData[]} props.attributes.headings A list of data for each heading in the post.
* @param {boolean} props.attributes.onlyIncludeCurrentPage Whether to only include headings from the current page (if the post is paginated).
* @param {string} props.clientId
* @param {(attributes: Object) => void} props.setAttributes
*
* @return {WPComponent} The component.
*/
export default function TableOfContentsEdit(_ref) {
let {
attributes: {
headings = [],
onlyIncludeCurrentPage
},
clientId,
setAttributes
} = _ref;
const blockProps = useBlockProps();
const disabledRef = useDisabled();
const canInsertList = useSelect(select => {
const {
getBlockRootClientId,
canInsertBlockType
} = select(blockEditorStore);
const rootClientId = getBlockRootClientId(clientId);
return canInsertBlockType('core/list', rootClientId);
}, [clientId]);
const {
__unstableMarkNextChangeAsNotPersistent,
replaceBlocks
} = useDispatch(blockEditorStore);
/**
* The latest heading data, or null if the new data deeply equals the saved
* headings attribute.
*
* Since useSelect forces a re-render when its return value is shallowly
* inequal to its prior call, we would be re-rendering this block every time
* the stores change, even if the latest headings were deeply equal to the
* ones saved in the block attributes.
*
* By returning null when they're equal, we reduce that to 2 renders: one
* when there are new latest headings (and so it returns them), and one when
* they haven't changed (so it returns null). As long as the latest heading
* data remains the same, further calls of the useSelect callback will
* continue to return null, thus preventing any forced re-renders.
*/
const latestHeadings = useSelect(select => {
var _editorSelectors$getP;
const {
getBlockAttributes,
getBlockName,
getClientIdsWithDescendants,
__experimentalGetGlobalBlocksByName: getGlobalBlocksByName
} = select(blockEditorStore); // FIXME: @wordpress/block-library should not depend on @wordpress/editor.
// Blocks can be loaded into a *non-post* block editor, so to avoid
// declaring @wordpress/editor as a dependency, we must access its
// store by string. When the store is not available, editorSelectors
// will be null, and the block's saved markup will lack permalinks.
// eslint-disable-next-line @wordpress/data-no-store-string-literals
const editorSelectors = select('core/editor');
const pageBreakClientIds = getGlobalBlocksByName('core/nextpage');
const isPaginated = pageBreakClientIds.length !== 0; // Get the client ids of all blocks in the editor.
const allBlockClientIds = getClientIdsWithDescendants(); // If onlyIncludeCurrentPage is true, calculate the page (of a paginated post) this block is part of, so we know which headings to include; otherwise, skip the calculation.
let tocPage = 1;
if (isPaginated && onlyIncludeCurrentPage) {
// We can't use getBlockIndex because it only returns the index
// relative to sibling blocks.
const tocIndex = allBlockClientIds.indexOf(clientId);
for (const [blockIndex, blockClientId] of allBlockClientIds.entries()) {
// If we've reached blocks after the Table of Contents, we've
// finished calculating which page the block is on.
if (blockIndex >= tocIndex) {
break;
}
if (getBlockName(blockClientId) === 'core/nextpage') {
tocPage++;
}
}
}
const _latestHeadings = [];
/** The page (of a paginated post) a heading will be part of. */
let headingPage = 1;
/**
* A permalink to the current post. If the core/editor store is
* unavailable, this variable will be null.
*/
const permalink = (_editorSelectors$getP = editorSelectors === null || editorSelectors === void 0 ? void 0 : editorSelectors.getPermalink()) !== null && _editorSelectors$getP !== void 0 ? _editorSelectors$getP : null;
let headingPageLink = null; // If the core/editor store is available, we can add permalinks to the
// generated table of contents.
if (typeof permalink === 'string') {
headingPageLink = isPaginated ? addQueryArgs(permalink, {
page: headingPage
}) : permalink;
}
for (const blockClientId of allBlockClientIds) {
const blockName = getBlockName(blockClientId);
if (blockName === 'core/nextpage') {
headingPage++; // If we're only including headings from the current page (of
// a paginated post), then exit the loop if we've reached the
// pages after the one with the Table of Contents block.
if (onlyIncludeCurrentPage && headingPage > tocPage) {
break;
}
if (typeof permalink === 'string') {
headingPageLink = addQueryArgs(removeQueryArgs(permalink, ['page']), {
page: headingPage
});
}
} // If we're including all headings or we've reached headings on
// the same page as the Table of Contents block, add them to the
// list.
else if (!onlyIncludeCurrentPage || headingPage === tocPage) {
if (blockName === 'core/heading') {
const headingAttributes = getBlockAttributes(blockClientId);
const canBeLinked = typeof headingPageLink === 'string' && typeof headingAttributes.anchor === 'string' && headingAttributes.anchor !== '';
_latestHeadings.push({
// Convert line breaks to spaces, and get rid of HTML tags in the headings.
content: stripHTML(headingAttributes.content.replace(/(<br *\/?>)+/g, ' ')),
level: headingAttributes.level,
link: canBeLinked ? `${headingPageLink}#${headingAttributes.anchor}` : null
});
}
}
}
if (isEqual(headings, _latestHeadings)) {
return null;
}
return _latestHeadings;
}, [clientId, onlyIncludeCurrentPage, headings]);
useEffect(() => {
if (latestHeadings !== null) {
// This is required to keep undo working and not create 2 undo steps
// for each heading change.
__unstableMarkNextChangeAsNotPersistent();
setAttributes({
headings: latestHeadings
});
}
}, [latestHeadings]);
const headingTree = linearToNestedHeadingList(headings);
const toolbarControls = canInsertList && createElement(BlockControls, null, createElement(ToolbarGroup, null, createElement(ToolbarButton, {
onClick: () => replaceBlocks(clientId, createBlock('core/list', {
ordered: true,
values: renderToString(createElement(TableOfContentsList, {
nestedHeadingList: headingTree
}))
}))
}, __('Convert to static list'))));
const inspectorControls = createElement(InspectorControls, null, createElement(PanelBody, {
title: __('Settings')
}, createElement(ToggleControl, {
label: __('Only include current page'),
checked: onlyIncludeCurrentPage,
onChange: value => setAttributes({
onlyIncludeCurrentPage: value
}),
help: onlyIncludeCurrentPage ? __('Only including headings from the current page (if the post is paginated).') : __('Toggle to only include headings from the current page (if the post is paginated).')
}))); // If there are no headings or the only heading is empty.
// Note that the toolbar controls are intentionally omitted since the
// "Convert to static list" option is useless to the placeholder state.
if (headings.length === 0) {
return createElement(Fragment, null, createElement("div", blockProps, createElement(Placeholder, {
icon: createElement(BlockIcon, {
icon: icon
}),
label: "Table of Contents",
instructions: __('Start adding Heading blocks to create a table of contents. Headings with HTML anchors will be linked here.')
})), inspectorControls);
}
return createElement(Fragment, null, createElement("nav", blockProps, createElement("ol", {
ref: disabledRef
}, createElement(TableOfContentsList, {
nestedHeadingList: headingTree
}))), toolbarControls, inspectorControls);
}
//# sourceMappingURL=edit.js.map