diff --git a/blocks/api/index.js b/blocks/api/index.js index 897aba32f31761..5d93473c79f193 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -9,6 +9,7 @@ export { export { default as parse, getBlockAttributes, + parseFootnotesFromContent, parseWithAttributeSchema, } from './parser'; export { default as rawHandler, getPhrasingContentSchema } from './raw-handling'; diff --git a/blocks/api/parser.js b/blocks/api/parser.js index 99d13fbb83e8fb..f64c0a87375ec3 100644 --- a/blocks/api/parser.js +++ b/blocks/api/parser.js @@ -205,6 +205,30 @@ export function getAttributesAndInnerBlocksFromDeprecatedVersion( blockType, inn } } +/** + * Parses the content and extracts the list of footnotes. + * + * @param {?Array} content The content to parse. + * + * @return {Array} Array of footnote ids. + */ +export function parseFootnotesFromContent( content ) { + if ( ! content || ! Array.isArray( content ) ) { + return []; + } + + return content.reduce( ( footnotes, element ) => { + if ( element.type === 'sup' && element.props[ 'data-wp-footnote-id' ] ) { + return [ + ...footnotes, + { id: element.props[ 'data-wp-footnote-id' ] }, + ]; + } + + return footnotes; + }, [] ); +} + /** * Creates a block with fallback to the unknown type handler. * diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index 1eacdc317e37f2..b039a66bac0720 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -5,6 +5,7 @@ import { getBlockAttribute, getBlockAttributes, asType, + parseFootnotesFromContent, createBlockWithFallback, getAttributesAndInnerBlocksFromDeprecatedVersion, default as parse, @@ -312,6 +313,25 @@ describe( 'block parser', () => { } ); } ); + describe( 'parseFootnotesFromContent', () => { + it( 'should return empty array if there is no content', () => { + const footnotes = parseFootnotesFromContent(); + + expect( footnotes ).toEqual( [] ); + } ); + it( 'should parse content and return footnote ids', () => { + const content = [ + 'Lorem ipsum', + { type: 'sup', props: { 'data-wp-footnote-id': '12345' } }, + 'is a text', + ]; + + const footnotes = parseFootnotesFromContent( content ); + + expect( footnotes ).toEqual( [ { id: '12345' } ] ); + } ); + } ); + describe( 'createBlockWithFallback', () => { it( 'should create the requested block if it exists', () => { registerBlockType( 'core/test-block', defaultBlockSettings ); diff --git a/core-blocks/footnotes/edit.js b/core-blocks/footnotes/edit.js new file mode 100644 index 00000000000000..3e952ce4e46db0 --- /dev/null +++ b/core-blocks/footnotes/edit.js @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { withSelect } from '@wordpress/data'; +import { RichText } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { getFootnoteByUid, orderFootnotes } from './footnotes-utils.js'; +import './editor.scss'; + +class FootnotesEdit extends Component { + constructor() { + super( ...arguments ); + + this.state = { + editable: null, + }; + } + + onChange( footnoteUid ) { + return ( nextValue ) => { + const { attributes, orderedFootnoteUids, setAttributes } = this.props; + + const nextFootnotes = orderedFootnoteUids.map( ( { id } ) => { + if ( id === footnoteUid ) { + return { + id, + text: nextValue, + }; + } + + return getFootnoteByUid( attributes.footnotes, id ); + } ); + + setAttributes( { + footnotes: nextFootnotes, + } ); + }; + } + + onSetActiveEditable( id ) { + return () => { + this.setState( { editable: id } ); + }; + } + + render() { + const { attributes, editable, orderedFootnoteUids, isSelected } = this.props; + const orderedFootnotes = orderFootnotes( attributes.footnotes, orderedFootnoteUids ); + + return ( +
    + { orderedFootnotes.map( ( footnote ) => ( +
  1. + +
  2. + ) ) } +
+ ); + } +} + +export default withSelect( ( select ) => ( { + orderedFootnoteUids: select( 'core/editor' ).getFootnotes(), +} ) )( FootnotesEdit ); diff --git a/core-blocks/footnotes/editor.scss b/core-blocks/footnotes/editor.scss new file mode 100644 index 00000000000000..cdb6a814ceb8c9 --- /dev/null +++ b/core-blocks/footnotes/editor.scss @@ -0,0 +1,3 @@ +.edit-post-visual-editor .blocks-footnotes__footnotes-list { + padding-left: 1.3em; +} diff --git a/core-blocks/footnotes/footnotes-utils.js b/core-blocks/footnotes/footnotes-utils.js new file mode 100644 index 00000000000000..14a6de0f99134c --- /dev/null +++ b/core-blocks/footnotes/footnotes-utils.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * Given an array of footnotes and a UID, returns the footnote object associated + * with that UID. If the array doesn't contain the footnote, a footnote object is + * returned with the given ID and an empty text. + * + * @param {Array} footnotes Array of footnotes. + * @param {Array} footnoteUID UID of the footnote to return. + * + * @return {Object} Footnote object with the id and the text of the footnote. If + * the footnote doesn't exist in the array, the text is an empty string. + */ +const getFootnoteByUid = function( footnotes, footnoteUID ) { + const filteredFootnotes = footnotes.filter( + ( footnote ) => footnote.id === footnoteUID ); + + return get( filteredFootnotes, [ 0 ], { id: footnoteUID, text: '' } ); +}; + +/** + * Orders an array of footnotes based on another array with the footnote UIDs + * ordered. + * + * @param {Array} footnotes Array of unordered footnotes. + * @param {Array} orderedFootnotesUids Array of ordered footnotes UIDs. Every + * element of the array must be an object with an id property, like the one + * returned after parsing the attributes. + * + * @return {Array} Array of footnotes ordered. + */ +const orderFootnotes = function( footnotes, orderedFootnotesUids ) { + return orderedFootnotesUids.map( + ( { id } ) => getFootnoteByUid( footnotes, id ) ); +}; + +export { getFootnoteByUid, orderFootnotes }; diff --git a/core-blocks/footnotes/index.js b/core-blocks/footnotes/index.js new file mode 100644 index 00000000000000..b9927fd39edbfc --- /dev/null +++ b/core-blocks/footnotes/index.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import FootnotesEdit from './edit.js'; +import { orderFootnotes } from './footnotes-utils.js'; +import './style.scss'; + +export const name = 'core/footnotes'; + +export const settings = { + title: __( 'Footnotes' ), + description: __( 'List of footnotes from the article' ), + category: 'common', + useOnce: true, + keywords: [ __( 'footnotes' ), __( 'references' ) ], + + attributes: { + footnotes: { + type: 'array', + source: 'query', + selector: 'li', + query: { + id: { + source: 'attribute', + attribute: 'id', + }, + text: { + source: 'children', + }, + }, + default: [], + }, + }, + + edit: FootnotesEdit, + + save( { attributes } ) { + const orderedFootnoteUids = select( 'core/editor' ).getFootnotes(); + const footnotes = orderedFootnoteUids && orderedFootnoteUids.length ? + orderFootnotes( attributes.footnotes, orderedFootnoteUids ) : + attributes.footnotes; + + return ( +
+
    + { footnotes.map( ( footnote ) => ( +
  1. + { footnote.text } +
  2. + ) ) } +
+
+ ); + }, +}; diff --git a/core-blocks/footnotes/style.scss b/core-blocks/footnotes/style.scss new file mode 100644 index 00000000000000..027f8f644c5717 --- /dev/null +++ b/core-blocks/footnotes/style.scss @@ -0,0 +1,9 @@ +.post, +.edit-post-visual-editor { + counter-reset: footnotes; +} + +.wp-footnote::before { + counter-increment: footnotes; + content: counter(footnotes); +} diff --git a/core-blocks/footnotes/test/__snapshots__/index.js.snap b/core-blocks/footnotes/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..819a2eeb733fe0 --- /dev/null +++ b/core-blocks/footnotes/test/__snapshots__/index.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`core/footnotes block edit matches snapshot 1`] = ` +
    +`; diff --git a/core-blocks/footnotes/test/footnotes-utils.js b/core-blocks/footnotes/test/footnotes-utils.js new file mode 100644 index 00000000000000..4a4fde18c219a1 --- /dev/null +++ b/core-blocks/footnotes/test/footnotes-utils.js @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import { getFootnoteByUid, orderFootnotes } from '../footnotes-utils.js'; + +describe( 'footnotes utils', () => { + describe( 'getFootnoteByUid', () => { + it( 'should return footnote associated with the id', () => { + const footnote = { id: 'abcd', text: 'ABCD' }; + const id = 'abcd'; + + expect( getFootnoteByUid( [ footnote ], id ) ).toEqual( footnote ); + } ); + + it( 'should return a footnote without text when the id is not found', () => { + const emptyFootnote = { id: 'abcd', text: '' }; + const id = 'abcd'; + + expect( getFootnoteByUid( [], id ) ).toEqual( emptyFootnote ); + } ); + } ); + + describe( 'orderFootnotes', () => { + it( 'should return ordered footnotes', () => { + const footnote1 = { id: 'abcd1', text: 'ABCD1' }; + const footnote2 = { id: 'abcd2', text: 'ABCD2' }; + const footnotes = [ footnote1, footnote2 ]; + const orderedFootnoteUids = [ { id: footnote2.id }, { id: footnote1.id } ]; + + expect( orderFootnotes( footnotes, orderedFootnoteUids ) ) + .toEqual( [ footnote2, footnote1 ] ); + } ); + } ); +} ); diff --git a/core-blocks/footnotes/test/index.js b/core-blocks/footnotes/test/index.js new file mode 100644 index 00000000000000..928f10c8713f3f --- /dev/null +++ b/core-blocks/footnotes/test/index.js @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { name, settings } from '../'; +import { blockEditRender } from '../../test/helpers'; +import '@wordpress/editor'; + +describe( 'core/footnotes', () => { + test( 'block edit matches snapshot', () => { + const wrapper = blockEditRender( name, settings ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/core-blocks/index.js b/core-blocks/index.js index a6c83b08da036f..0a71e8db3e16cd 100644 --- a/core-blocks/index.js +++ b/core-blocks/index.js @@ -25,6 +25,7 @@ import * as columns from './columns'; import * as coverImage from './cover-image'; import * as embed from './embed'; import * as freeform from './freeform'; +import * as footnotes from './footnotes'; import * as html from './html'; import * as latestPosts from './latest-posts'; import * as list from './list'; @@ -65,6 +66,7 @@ export const registerCoreBlocks = () => { ...embed.common, ...embed.others, freeform, + footnotes, html, latestPosts, more, diff --git a/core-blocks/paragraph/index.js b/core-blocks/paragraph/index.js index d7df95c47dc7ef..d3cc887e14547f 100644 --- a/core-blocks/paragraph/index.js +++ b/core-blocks/paragraph/index.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { isFinite, find, omit } from 'lodash'; +import { isEqual, isFinite, find, omit } from 'lodash'; /** * WordPress dependencies @@ -31,7 +31,11 @@ import { PanelColor, RichText, } from '@wordpress/editor'; -import { createBlock, getPhrasingContentSchema } from '@wordpress/blocks'; +import { + createBlock, + getPhrasingContentSchema, + parseFootnotesFromContent, +} from '@wordpress/blocks'; /** * Internal dependencies @@ -140,6 +144,7 @@ class ParagraphBlock extends Component { render() { const { attributes, + updateFootnotes, setAttributes, insertBlocksAfter, mergeBlocks, @@ -156,6 +161,7 @@ class ParagraphBlock extends Component { const { align, + blockFootnotes, content, dropCap, placeholder, @@ -163,6 +169,8 @@ class ParagraphBlock extends Component { const fontSize = this.getFontSize(); + const formattingControls = [ 'bold', 'italic', 'strikethrough', 'link', 'footnote' ]; + return ( @@ -227,20 +235,34 @@ class ParagraphBlock extends Component { } } value={ content } onChange={ ( nextContent ) => { + const previousFootnotes = blockFootnotes || []; + const footnotes = parseFootnotesFromContent( nextContent ); + setAttributes( { content: nextContent, + blockFootnotes: footnotes, } ); + + if ( ! isEqual( previousFootnotes, footnotes ) ) { + updateFootnotes( footnotes ); + } } } onSplit={ insertBlocksAfter ? ( before, after, ...blocks ) => { if ( after ) { - blocks.push( createBlock( name, { content: after } ) ); + blocks.push( createBlock( name, { + content: after, + blockFootnotes: parseFootnotesFromContent( after ), + } ) ); } insertBlocksAfter( blocks ); if ( before ) { - setAttributes( { content: before } ); + setAttributes( { + content: before, + blockFootnotes: parseFootnotesFromContent( before ), + } ); } else { onReplace( [] ); } @@ -251,6 +273,7 @@ class ParagraphBlock extends Component { onReplace={ this.onReplace } onRemove={ () => onReplace( [] ) } placeholder={ placeholder || __( 'Add text or type / to add content' ) } + formattingControls={ formattingControls } /> @@ -297,6 +320,17 @@ const schema = { customFontSize: { type: 'number', }, + blockFootnotes: { + type: 'array', + source: 'query', + selector: 'sup', + query: { + id: { + source: 'attribute', + attribute: 'data-wp-footnote-id', + }, + }, + }, }; export const name = 'core/paragraph'; diff --git a/core-blocks/test/fixtures/core__columns.json b/core-blocks/test/fixtures/core__columns.json index baf28cfd7f291b..c08d20bead8bdf 100644 --- a/core-blocks/test/fixtures/core__columns.json +++ b/core-blocks/test/fixtures/core__columns.json @@ -12,6 +12,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "Column One, Paragraph One" ], @@ -26,6 +27,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "Column One, Paragraph Two" ], @@ -40,6 +42,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "Column Two, Paragraph One" ], @@ -54,6 +57,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "Column Three, Paragraph One" ], diff --git a/core-blocks/test/fixtures/core__footnotes.html b/core-blocks/test/fixtures/core__footnotes.html new file mode 100644 index 00000000000000..ed37491b35295a --- /dev/null +++ b/core-blocks/test/fixtures/core__footnotes.html @@ -0,0 +1,3 @@ + +
    1. Reference 1
    2. Reference 2
    + diff --git a/core-blocks/test/fixtures/core__footnotes.json b/core-blocks/test/fixtures/core__footnotes.json new file mode 100644 index 00000000000000..0b6034c10216dc --- /dev/null +++ b/core-blocks/test/fixtures/core__footnotes.json @@ -0,0 +1,21 @@ +[ + { + "uid": "_uid_0", + "name": "core/footnotes", + "isValid": true, + "attributes": { + "footnotes": [ + { + "id": "7edc47cb-3fe1-4ce0-ae6f-5e23e1e67aa2", + "text": [ "Reference 1" ] + }, + { + "id": "2e5f8a19-d9cd-4898-8686-cb6518450dc5", + "text": [ "Reference 2" ] + } + ] + }, + "innerBlocks": [], + "originalContent": "
    1. Reference 1
    2. Reference 2
    " + } +] diff --git a/core-blocks/test/fixtures/core__footnotes.parsed.json b/core-blocks/test/fixtures/core__footnotes.parsed.json new file mode 100644 index 00000000000000..e40bce6ab20697 --- /dev/null +++ b/core-blocks/test/fixtures/core__footnotes.parsed.json @@ -0,0 +1,12 @@ +[ + { + "blockName": "core/footnotes", + "attrs": null, + "innerBlocks": [], + "innerHTML": "\n
    1. Reference 1
    2. Reference 2
    \n" + }, + { + "attrs": {}, + "innerHTML": "\n" + } +] diff --git a/core-blocks/test/fixtures/core__footnotes.serialized.html b/core-blocks/test/fixtures/core__footnotes.serialized.html new file mode 100644 index 00000000000000..5f6c80ff1eb28e --- /dev/null +++ b/core-blocks/test/fixtures/core__footnotes.serialized.html @@ -0,0 +1,8 @@ + +
    +
      +
    1. Reference 1
    2. +
    3. Reference 2
    4. +
    +
    + diff --git a/core-blocks/test/fixtures/core__paragraph__align-right.json b/core-blocks/test/fixtures/core__paragraph__align-right.json index 731657d06705f8..1690e7a7927aa8 100644 --- a/core-blocks/test/fixtures/core__paragraph__align-right.json +++ b/core-blocks/test/fixtures/core__paragraph__align-right.json @@ -4,6 +4,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "... like this one, which is separate from the above and right aligned." ], diff --git a/core-blocks/test/fixtures/core__paragraph__deprecated.json b/core-blocks/test/fixtures/core__paragraph__deprecated.json index 7fe227a9e14cb4..1685fb1a1dfef2 100644 --- a/core-blocks/test/fixtures/core__paragraph__deprecated.json +++ b/core-blocks/test/fixtures/core__paragraph__deprecated.json @@ -4,6 +4,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ { "key": "html", diff --git a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json index c2a6c0d770de73..3ce42dbb1e3aee 100644 --- a/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json +++ b/core-blocks/test/fixtures/core__pullquote__multi-paragraph.json @@ -15,7 +15,7 @@ "Paragraph ", { "type": "strong", - "key": "_domReact71", + "key": "_domReact73", "ref": null, "props": { "children": "one" diff --git a/core-blocks/test/fixtures/core__text__converts-to-paragraph.json b/core-blocks/test/fixtures/core__text__converts-to-paragraph.json index f9ff727423d80a..62899a2cc2697f 100644 --- a/core-blocks/test/fixtures/core__text__converts-to-paragraph.json +++ b/core-blocks/test/fixtures/core__text__converts-to-paragraph.json @@ -4,6 +4,7 @@ "name": "core/paragraph", "isValid": true, "attributes": { + "blockFootnotes": [], "content": [ "This is an old-style text block. Changed to ", { diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 580b22c3d87657..f32fd8dbb05077 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -51,6 +51,7 @@ import InserterWithShortcuts from '../inserter-with-shortcuts'; import Inserter from '../inserter'; import withHoverAreas from './with-hover-areas'; import { createInnerBlockList } from '../../utils/block-list'; +import { updateFootnotesBlockVisibility } from '../../utils/footnotes'; const { BACKSPACE, DELETE, ENTER } = keycodes; @@ -60,6 +61,7 @@ export class BlockListBlock extends Component { this.setBlockListRef = this.setBlockListRef.bind( this ); this.bindBlockNode = this.bindBlockNode.bind( this ); + this.updateFootnotes = this.updateFootnotes.bind( this ); this.setAttributes = this.setAttributes.bind( this ); this.maybeHover = this.maybeHover.bind( this ); this.hideHoverEffects = this.hideHoverEffects.bind( this ); @@ -189,6 +191,15 @@ export class BlockListBlock extends Component { } } + updateFootnotes( currentBlockFootnotes, updatedBlocks ) { + const { block } = this.props; + + updateFootnotesBlockVisibility( { + ...updatedBlocks, + [ block.uid ]: currentBlockFootnotes, + } ); + } + setAttributes( attributes ) { const { block, onChange } = this.props; const type = getBlockType( block.name ); @@ -556,6 +567,7 @@ export class BlockListBlock extends Component { name={ blockName } isSelected={ isSelected } attributes={ block.attributes } + updateFootnotes={ this.updateFootnotes } setAttributes={ this.setAttributes } insertBlocksAfter={ isLocked ? undefined : this.insertBlocksAfter } onReplace={ isLocked ? undefined : onReplace } diff --git a/editor/components/block-settings-menu/block-remove-button.js b/editor/components/block-settings-menu/block-remove-button.js index a9fe8f121eb373..e7d46afb27f60f 100644 --- a/editor/components/block-settings-menu/block-remove-button.js +++ b/editor/components/block-settings-menu/block-remove-button.js @@ -11,6 +11,11 @@ import { IconButton } from '@wordpress/components'; import { compose } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { updateFootnotesBlockVisibility } from '../../utils/footnotes'; + export function BlockRemoveButton( { onRemove, onClick = noop, isLocked, role, ...props } ) { if ( isLocked ) { return null; @@ -33,6 +38,7 @@ export function BlockRemoveButton( { onRemove, onClick = noop, isLocked, role, . export default compose( withDispatch( ( dispatch, { uids } ) => ( { onRemove() { + updateFootnotesBlockVisibility( {}, uids ); dispatch( 'core/editor' ).removeBlocks( uids ); }, } ) ), diff --git a/editor/components/rich-text/format-toolbar/index.js b/editor/components/rich-text/format-toolbar/index.js index 62d48671769de4..a49280152601df 100644 --- a/editor/components/rich-text/format-toolbar/index.js +++ b/editor/components/rich-text/format-toolbar/index.js @@ -47,6 +47,11 @@ const FORMATTING_CONTROLS = [ shortcut: displayShortcut.primary( 'k' ), format: 'link', }, + { + icon: 'editor-textcolor', // TODO: Need proper footnote icon + title: __( 'Footnote' ), + format: 'footnote', + }, ]; // Default controls shown if no `enabledControls` prop provided @@ -114,6 +119,12 @@ class FormatToolbar extends Component { }; } + addFootnote() { + return () => { + this.props.onAddFootnote(); + }; + } + toggleLinkSettingsVisibility() { this.setState( ( state ) => ( { settingsVisible: ! state.settingsVisible } ) ); } @@ -185,6 +196,13 @@ class FormatToolbar extends Component { }; } + if ( control.format === 'footnote' ) { + return { + ...control, + onClick: this.addFootnote(), + }; + } + return { ...control, onClick: this.toggleFormat( control.format ), diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js index d1d54bf312d3eb..bab62ff23717a7 100644 --- a/editor/components/rich-text/index.js +++ b/editor/components/rich-text/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import uuid from 'uuid/v4'; import classnames from 'classnames'; import { last, @@ -18,6 +19,7 @@ import 'element-closest'; /** * WordPress dependencies */ +import { __ } from '@wordpress/i18n'; import { Component, Fragment, compose, RawHTML, createRef } from '@wordpress/element'; import { isHorizontalEdge, @@ -119,6 +121,7 @@ export class RichText extends Component { this.onKeyDown = this.onKeyDown.bind( this ); this.onKeyUp = this.onKeyUp.bind( this ); this.changeFormats = this.changeFormats.bind( this ); + this.addFootnote = this.addFootnote.bind( this ); this.onPropagateUndo = this.onPropagateUndo.bind( this ); this.onPastePreProcess = this.onPastePreProcess.bind( this ); this.onPaste = this.onPaste.bind( this ); @@ -829,6 +832,15 @@ export class RichText extends Component { } ) ); } + addFootnote() { + this.editor.selection.collapse(); + if ( this.editor.selection.getNode().tagName === 'SUP' ) { + return; + } + const uid = uuid(); + this.editor.insertContent( `${ __( 'See footnote' ) } ` ); + } + /** * Calling onSplit means we need to abort the change done by TinyMCE. * we need to call updateContent to restore the initial content before calling onSplit. @@ -875,6 +887,7 @@ export class RichText extends Component { focusPosition={ this.state.focusPosition } formats={ this.state.formats } onChange={ this.changeFormats } + onAddFootnote={ this.addFootnote } enabledControls={ formattingControls } customControls={ formatters } /> diff --git a/editor/store/actions.js b/editor/store/actions.js index 9028f989452dda..8c157e64bdec8e 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -285,6 +285,18 @@ export function insertBlocks( blocks, index, rootUID ) { }; } +/** + * Returns an action object used in signalling that a footnotes block should be + * added to the block list. + * + * @return {Object} Action object + */ +export function insertFootnotesBlock() { + return { + ...insertBlock( createBlock( 'core/footnotes' ) ), + }; +} + /** * Returns an action object used in signalling that the insertion point should * be shown. @@ -646,6 +658,7 @@ export function convertBlockToShared( uid ) { uid, }; } + /** * Returns an action object used in signalling that a new block of the default * type should be added to the block list. diff --git a/editor/store/selectors.js b/editor/store/selectors.js index cc0707801aebd7..9603243764c17f 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -713,6 +713,50 @@ export function getNextBlockUid( state, startUID ) { return getAdjacentBlockUid( state, startUID, 1 ); } +/** + * Returns the UID from the footnotes block if it exists, or null if there isn't + * any footnotes block. + * + * @param {Object} state Global application state. + * + * @return {string|null} Footnotes block's UID, or null if none exists. + */ +export function getFootnotesBlockUid( state ) { + const blocks = getBlocks( state ); + + for ( let i = 0; i < blocks.length; i++ ) { + if ( blocks[ i ].name === 'core/footnotes' ) { + return blocks[ i ].uid; + } + } + + return null; +} + +/** + * Returns an array with all footnote UIDs contained in the specified block + * including its children. If no block UID is specified, it returns the array + * of footnotes of the entire post. + * + * @param {Object} state Global application state. + * @param {?string} rootUID Optional root UID of the block to search footnotes in. + * + * @return {Array} Footnote ids. + */ +export function getFootnotes( state, rootUID = '' ) { + const blockFootnotes = get( state.editor.present.blocksByUID[ rootUID ], + [ 'attributes', 'blockFootnotes' ], [] ); + const innerBlocksUids = getBlockOrder( state, rootUID ); + + return innerBlocksUids.reduce( + ( footnotes, blockUid ) => [ + ...footnotes, + ...getFootnotes( state, blockUid ), + ], + blockFootnotes + ); +} + /** * Returns the initial caret position for the selected block. * This position is to used to position the caret properly when the selected block changes. diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js index 050ff2473015b3..af4c38e87cf96e 100644 --- a/editor/store/test/actions.js +++ b/editor/store/test/actions.js @@ -1,3 +1,11 @@ +/** + * WordPress dependencies + */ +import { + unregisterBlockType, + registerBlockType, +} from '@wordpress/blocks'; + /** * Internal dependencies */ @@ -24,6 +32,7 @@ import { replaceBlock, insertBlock, insertBlocks, + insertFootnotesBlock, showInsertionPoint, hideInsertionPoint, editPost, @@ -213,6 +222,38 @@ describe( 'actions', () => { } ); } ); + describe( 'insertFootnotesBlock', () => { + beforeEach( () => { + registerBlockType( 'core/footnotes', { + title: 'Footnotes', + category: 'common', + save: () => null, + attributes: { + footnotes: [], + }, + } ); + } ); + + afterEach( () => { + unregisterBlockType( 'core/footnotes' ); + } ); + + it( 'should return the INSERT_BLOCKS action', () => { + const block = { + attributes: {}, + innerBlocks: [], + isValid: true, + name: 'core/footnotes', + uid: expect.any( String ), + }; + expect( insertFootnotesBlock() ).toEqual( { + type: 'INSERT_BLOCKS', + blocks: [ block ], + time: expect.any( Number ), + } ); + } ); + } ); + describe( 'showInsertionPoint', () => { it( 'should return the SHOW_INSERTION_POINT action', () => { expect( showInsertionPoint() ).toEqual( { diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 602672368f41d6..4b5fd5e3f48f19 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -63,6 +63,8 @@ const { getBlockIndex, getPreviousBlockUid, getNextBlockUid, + getFootnotesBlockUid, + getFootnotes, isBlockSelected, isBlockWithinSelection, hasMultiSelection, @@ -2030,6 +2032,121 @@ describe( 'selectors', () => { } ); } ); + describe( 'getFootnotesBlockUid', () => { + it( 'should return the footnotes block\'s UID', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUID: { + uid1: { + uid: 'uid1', + name: 'core/footnotes', + attributes: {}, + }, + }, + blockOrder: { + '': [ 'uid1' ], + uid1: [], + }, + edits: {}, + }, + }, + }; + + expect( getFootnotesBlockUid( state ) ).toEqual( 'uid1' ); + } ); + + it( 'should return null if there isn\'t any footnotes block', () => { + const state = { + editor: { + present: { + blocksByUID: [], + blockOrder: { '': [] }, + }, + }, + }; + + expect( getFootnotesBlockUid( state ) ).toBeNull(); + } ); + } ); + + describe( 'getFootnotes', () => { + it( 'should return the footnotes array ordered', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUID: { + uid1: { + uid: 'uid1', + attributes: { + blockFootnotes: [ { id: '123' }, { id: '456' } ], + }, + }, + uid2: { + uid: 'uid2', + attributes: { + blockFootnotes: [ { id: '789' } ], + }, + }, + }, + blockOrder: { + '': [ 'uid2', 'uid1' ], + uid1: [], + uid2: [], + }, + edits: {}, + }, + }, + }; + + expect( getFootnotes( state ) ).toEqual( + [ { id: '789' }, { id: '123' }, { id: '456' } ] ); + } ); + + it( 'should return the footnotes from inner blocks', () => { + const state = { + currentPost: {}, + editor: { + present: { + blocksByUID: { + uid1: { + uid: 'uid1', + }, + uid2: { + uid: 'uid2', + attributes: { + blockFootnotes: [ { id: '123' } ], + }, + }, + }, + blockOrder: { + '': [ 'uid1' ], + uid1: [ 'uid2' ], + }, + edits: {}, + }, + }, + }; + + expect( getFootnotes( state ) ).toEqual( [ { id: '123' } ] ); + } ); + + it( 'should return empty array if there isn\'t any footnote', () => { + const state = { + editor: { + present: { + blocksByUID: [], + blockOrder: { '': [] }, + }, + }, + }; + + expect( getFootnotes( state ) ).toEqual( [] ); + } ); + } ); + describe( 'isBlockSelected', () => { it( 'should return true if the block is selected', () => { const state = { diff --git a/editor/utils/footnotes.js b/editor/utils/footnotes.js new file mode 100644 index 00000000000000..94934d4a406699 --- /dev/null +++ b/editor/utils/footnotes.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { dispatch, select } from '@wordpress/data'; + +/** + * Checks if the updated blocks contain footnotes. + * + * @param {Object} updatedBlocks Object containing the updated blocks in a hierarchical + * form with a blockFootnotes property which contains the new footnotes. + * + * @return {boolean} True if the updated blocks contain footnotes and false if they don't. + */ +const doUpdatedBlocksContainFootnotes = function( updatedBlocks ) { + for ( let i = 0; i < Object.keys( updatedBlocks ).length; i++ ) { + const uid = Object.keys( updatedBlocks )[ i ]; + + if ( updatedBlocks[ uid ] && updatedBlocks[ uid ].length ) { + return true; + } + } + + return false; +}; + +/** + * Checks if the provided list of blocks contain footnotes. If a block is in the list of + * updatedBlocks, the list of footnotes from updatedBlocks takes precedence. If a block + * uid matches the removedBlock parameter, the footnotes of that block and its children + * are ignored. + * + * @param {Array} blocks Array of blocks from the post + * @param {Object} updatedBlocks Object containing the updated blocks in a hierarchical + * form with a blockFootnotes property which contains the new footnotes. + * @param {?string} removedBlock Uid of the removed block. + * + * @return {boolean} True if the blocks contain footnotes and false if they don't. It + * also returns false if the array of blocks is empty. + */ +const doBlocksContainFootnotes = function( blocks, updatedBlocks, removedBlock ) { + if ( ! blocks ) { + return false; + } + + for ( let i = 0; i < blocks.length; i++ ) { + const block = blocks[ i ]; + + if ( block.uid === removedBlock ) { + continue; + } + + const blockFootnotes = updatedBlocks.hasOwnProperty( block.uid ) ? + updatedBlocks[ block.uid ] : get( block, [ 'attributes', 'blockFootnotes' ] ); + if ( blockFootnotes && blockFootnotes.length ) { + return true; + } + + if ( doBlocksContainFootnotes( block.innerBlocks, updatedBlocks, removedBlock ) ) { + return true; + } + } + + return false; +}; + +/** + * Checks if post being edited contains footnotes. + * + * @param {Object} updatedBlocks Object containing the updated blocks in a hierarchical + * form with a blockFootnotes property which contains the new footnotes. + * @param {?string} removedBlock Uid of the removed block. + * + * @return {boolean} True if the current edited post contains footnotes and + * false if it doesn't. + */ +const doesPostContainFootnotes = function( updatedBlocks, removedBlock ) { + if ( doUpdatedBlocksContainFootnotes( updatedBlocks ) ) { + return true; + } + + const blocks = select( 'core/editor' ).getBlocks(); + + return doBlocksContainFootnotes( blocks, updatedBlocks, removedBlock ); +}; + +/** + * Inserts the footnotes block or removes it depending on whether the post blocks contain + * footnotes or not. + * + * @param {Object} updatedBlocks Object containing the updated blocks in a hierarchical + * form with a blockFootnotes property which contains the new footnotes. + * @param {?string} removedBlock Uid of the removed block. + */ +export function updateFootnotesBlockVisibility( updatedBlocks, removedBlock = null ) { + const { insertFootnotesBlock, removeBlock } = dispatch( 'core/editor' ); + const footnotesBlockUid = select( 'core/editor' ).getFootnotesBlockUid(); + const shouldFootnotesBlockBeVisible = doesPostContainFootnotes( updatedBlocks, removedBlock ); + + if ( ! footnotesBlockUid && shouldFootnotesBlockBeVisible ) { + insertFootnotesBlock(); + } else if ( footnotesBlockUid && ! shouldFootnotesBlockBeVisible ) { + removeBlock( footnotesBlockUid ); + } +}