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 ) => (
+ -
+
+
+ ) ) }
+
+ );
+ }
+}
+
+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 ) => (
+ -
+ { footnote.text }
+
+ ) ) }
+
+
+ );
+ },
+};
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 @@
+
+
+
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": ""
+ }
+]
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\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 @@
+
+
+
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( ` ` );
+ }
+
/**
* 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 );
+ }
+}