Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Footnotes (again): MVP #47682

Closed
wants to merge 18 commits into from
122 changes: 122 additions & 0 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,125 @@ function gutenberg_register_vendor_scripts( $scripts ) {
// Enqueue stored styles.
add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' );
add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 );

/**
* Given a string of HTML, replaces all matching <data> tags with the result of
* the callback function.
*
* @param string $content The HTML content.
* @param string $type The type of data.
* @param callable $callback The callback function, called with the data
* attributes and fallback content.
*
* @return string The HTML content with the <data> tags replaced.
*/
function replaceDataByType( $content, $type, $callback ) {
if ( ! strpos( $content, '<data' ) ) {
return $content;
}

return preg_replace_callback(
'/<data ([^>]+)>(.*?)<\/data>/',
function( $matches ) use ( $type, $callback ) {
// shortcode_parse_atts works on HTML attributes too.
$attrs = shortcode_parse_atts( $matches[1] );
$fallback = $matches[2];

if ( ! isset( $attrs['value'] ) || $attrs['value'] !== $type ) {
return $matches[0];
}

$data = array();

foreach ( $attrs as $key => $value ) {
if ( strpos( $key, 'data-' ) === 0 ) {
$data[ substr( $key, 5 ) ] = $value;
}
}

return $callback( $data, $fallback );
},
$content
);
}

add_filter(
'render_block',
function( $block_content ) {
return replaceDataByType(
$block_content,
'core/current-year',
function() {
return '<time datetime=' . date( 'Y' ) . '>' . date( 'Y' ) . '</time>';
}
);
}
);


/**
* Add footnotes to the end of the content.
*
* @param string $content Content.
*
* @return string Content with footnotes.
*/
function gutenberg_footnotes_the_content( $content ) {
$notes = array();
$content = replaceDataByType(
$content,
'core/footnote',
function( $attributes, $note ) use ( &$notes ) {
$note = substr( $note, 1, -1 );
$id = md5( $note );

if ( isset( $notes[ $id ] ) ) {
$notes[ $id ]['count'] += 1;
} else {
$notes[ $id ] = array(
'note' => $note,
'count' => 1,
);
}

// List starts at 1. If the note already exists, use the existing index.
$index = 1 + array_search( $id, array_keys( $notes ), true );
$count = $notes[ $id ]['count'];

return (
'<sup><a class="note-link" href="#' . $id . '" id="' . $id . '-link-' . $count . '">[' . $index . ']</a></sup>'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Musing a bit here to explore ways this can be done with the HTML API. This is me playing with the idea and seeing how this need might be taken care of and lead the HTML API design…

This first one uses the Tag Processor in a way that's tightly coupled to internal details. It's not the way we want people to use the Tag Processor, but it's technically possible with what's in Core.

class FootNoteProcessor extends WP_HTML_Tag_Processor {
	public function replace_footnotes() {
		$notes = array();

		while ( $this->next_tag( 'data' ) ) {
			if ( 'core/footnote' !== $this->get_attribute( 'value' ) ) {
				continue;
			}

			// This bookmark stuff is internal. Possible to use it here now
			// like this, but not something intended to be used in practice
			// outside of the Processor classes.
			$this->set_bookmark( 'start' );
			$start = $this->bookmarks['start']->start;

			// Move to the very next tag token. If it isn't closing
			// the DATA tag then bail; it's not what we expect.
			if ( ! (
				$this->next_tag( array( 'tag_closers' => 'visit' ) ) &&
				'DATA' === $this->get_tag() &&
				$this->is_tag_closer()
			) ) {
				return false;
			}
			$this->set_bookmark( 'end' );

			// Grab the existing inner content.
			$note = substr(
				$this->html,
				$this->bookmarks['start']->end,
				$this->bookmarks['end']->start
			);

			$id = md5( $note );

			if ( isset( $notes[ $id ] ) ) {
				$notes[ $id ]['count'] += 1;
			} else {
				$notes[ $id ] = array(
					'note'  => $note,
					'count' => 1,
				);
			}

			// List starts at 1. If the note already exists, use the existing index.
			$index = 1 + array_search( $id, array_keys( $notes ), true );
			$count = $notes[ $id ]['count'];


			$footnote = new WP_HTML_Tag_Processor( "<sup><a>[{$index}]</a></sup>" );
			$footnote->next_tag( 'a' );
			$footnote->add_class( 'note-link' );
			$footnote->set_attribute( 'href', "#{$id}" );
			$footnote->set_attribute( 'id', "{$id}-link-{$count}" );

			// Replace the outer content with the new structure.
			$this->lexical_updates[] = new WP_HTML_Text_Replacement(
				$this->bookmarks['start']->start,
				$this->bookmarks['end']->end + 1,
				$footnote->get_updated_html()
			);

			$this->bookmarks['start']->start = $start;
			$this->bookmarks['start']->end   = $start;
			$this->seek( 'start' );
			$this->next_tag();
		}

		if ( empty( $notes ) ) {
			return true;
		}

		…
	}
}

$footnote_processor = new FootnoteProcessor( $block_html );
$footnote_processor->replace_footnotes();
$with_footnotes = $footnote_processor->get_updated_html();

The HTML processor should end up making this far less noisy and remove the internal coupling, but isn't available yet. I think that the code above will work today.

function replace_footnotes( $html ) {
	$p = new WP_HTML_Processor( $html );g
	$notes = array();

	while ( $p->select( 'data[value = "core/footnote"]' ) ) {
		$note = $p->get_inner_html();

		…

		$p->set_outer_html( $footnote->get_updated_html() );
	}

	if ( empty( $notes ) ) {
		return $p->get_updated_html();
	}

	…
}

And yesterday while exploring some unrelated bits I thought of an idea to use today's Tag Processor to generate the template in easier way, utilizing the funky HTML placeholder syntax I realized is possible through abuse of the "tag closer with invalid tag name is a comment" rule.

$footnote = wp_html_template(
	<<<HTML
	<sup>
		<a class="note-link" href="#</%id>" id="</%id>-link-</%count>">[</%index>]</a>
	</sup>
HTML,
	array(
		'id'    => $id,
		'index' => $index,
		'count' => $count
	)
);

);
}
);

if ( empty( $notes ) ) {
return $content;
}

$list = '<ol>';

foreach ( $notes as $id => $info ) {
$note = $info['note'];
$count = $info['count'];
$list .= '<li id="' . $id . '">';
$list .= $note;
$label = $count > 1 ?
/* translators: %s: footnote occurrence */
__( 'Back to content (%s)', 'gutenberg' ) :
__( 'Back to content', 'gutenberg' );
$links = '';
while ( $count ) {
$links = '<a href="#' . $id . '-link-' . $count . '" aria-label="' . sprintf( $label, $count ) . '">↩︎</a>' . $links;
$count--;
}
$list .= ' ' . $links;
$list .= '</li>';
}

$list .= '</ol>';

return $content . $list;
}

// To do: make it work with pagination, excerpt.
add_filter( 'the_content', 'gutenberg_footnotes_the_content' );
4 changes: 4 additions & 0 deletions packages/block-editor/src/components/block-context/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,8 @@ export function BlockContextProvider( { value, children } ) {
return <Context.Provider value={ nextValue } children={ children } />;
}

export function useBlockContext() {
return useContext( Context );
}

export default Context;
2 changes: 1 addition & 1 deletion packages/block-editor/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export { default as __experimentalBlockFullHeightAligmentControl } from './block
export { default as __experimentalBlockAlignmentMatrixControl } from './block-alignment-matrix-control';
export { default as BlockBreadcrumb } from './block-breadcrumb';
export { default as __experimentalUseBlockOverlayActive } from './block-content-overlay';
export { BlockContextProvider } from './block-context';
export { BlockContextProvider, useBlockContext } from './block-context';
export {
default as BlockControls,
BlockFormatControls,
Expand Down
11 changes: 11 additions & 0 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
useCallback,
forwardRef,
createContext,
createPortal,
} from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { children as childrenSource } from '@wordpress/blocks';
Expand Down Expand Up @@ -281,6 +282,7 @@ function RichTextWrapper(
getValue,
onChange,
ref: richTextRef,
replacementRefs,
} = useRichText( {
value: adjustedValue,
onChange( html, { __unstableFormats, __unstableText } ) {
Expand Down Expand Up @@ -414,6 +416,15 @@ function RichTextWrapper(
'rich-text'
) }
/>
{ replacementRefs.map( ( ref ) => {
const { render: Render } = formatTypes.find(
( { name } ) => name === ref.value
);
return (
ref &&
createPortal( <Render attributes={ ref.dataset } />, ref )
);
} ) }
</>
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/format-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"@wordpress/block-editor": "file:../block-editor",
"@wordpress/components": "file:../components",
"@wordpress/compose": "file:../compose",
"@wordpress/core-data": "file:../core-data",
"@wordpress/data": "file:../data",
"@wordpress/date": "file:../date",
"@wordpress/element": "file:../element",
"@wordpress/html-entities": "file:../html-entities",
"@wordpress/i18n": "file:../i18n",
Expand Down
43 changes: 43 additions & 0 deletions packages/format-library/src/current-year/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { insertObject } from '@wordpress/rich-text';
import { RichTextToolbarButton } from '@wordpress/block-editor';
import { formatListNumbered } from '@wordpress/icons';
import { dateI18n } from '@wordpress/date';

const name = 'core/current-year';
const title = __( 'Current Year' );

function Time() {
const year = dateI18n( 'Y', new Date() );
return <time dateTime={ year }>{ year }</time>;
}

export const currentYear = {
name,
title,
tagName: 'data',
render: Time,
saveFallback: Time,
edit( { isObjectActive, value, onChange, onFocus } ) {
function onClick() {
const newValue = insertObject( value, {
type: name,
} );
newValue.start = newValue.end - 1;
onChange( newValue );
onFocus();
}

return (
<RichTextToolbarButton
icon={ formatListNumbered }
title={ title }
onClick={ onClick }
isActive={ isObjectActive }
/>
);
},
};
8 changes: 8 additions & 0 deletions packages/format-library/src/default-formats.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { subscript } from './subscript';
import { superscript } from './superscript';
import { keyboard } from './keyboard';
import { unknown } from './unknown';
import { currentYear } from './current-year';
import { footnote } from './footnote';
import { postDate } from './post-date';
import { currentYear } from './current-year';

export default [
bold,
Expand All @@ -27,4 +31,8 @@ export default [
superscript,
keyboard,
unknown,
currentYear,
footnote,
postDate,
currentYear,
];
112 changes: 112 additions & 0 deletions packages/format-library/src/footnote/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { insertObject, useAnchor } from '@wordpress/rich-text';
import { RichTextToolbarButton } from '@wordpress/block-editor';
import { formatListNumbered } from '@wordpress/icons';
import { Popover, TextControl } from '@wordpress/components';

const name = 'core/footnote';
const title = __( 'Footnote' );

export const footnote = {
name,
title,
tagName: 'data',
attributes: {
note: ( element ) =>
element.innerHTML.replace( /^\[/, '' ).replace( /\]$/, '' ),
},
render() {
return (
<sup>
<a className="note-link" href="#placeholder">
{ '' }
</a>
</sup>
);
},
saveFallback( { attributes: { note } } ) {
return `[${ note }]`;
},
edit( {
isObjectActive,
value,
onChange,
onFocus,
contentRef,
activeObjectAttributes,
} ) {
function onClick() {
const newValue = insertObject( value, {
type: name,
attributes: {
note: '',
},
} );
newValue.start = newValue.end - 1;
onChange( newValue );
onFocus();
}

return (
<>
<RichTextToolbarButton
icon={ formatListNumbered }
title={ title }
onClick={ onClick }
isActive={ isObjectActive }
/>
{ isObjectActive && (
<InlineUI
value={ value }
onChange={ onChange }
activeObjectAttributes={ activeObjectAttributes }
contentRef={ contentRef }
/>
) }
</>
);
},
};

function InlineUI( { value, onChange, activeObjectAttributes, contentRef } ) {
const { note } = activeObjectAttributes;
const popoverAnchor = useAnchor( {
editableContentElement: contentRef.current,
settings: footnote,
} );

return (
<Popover
placement="bottom"
focusOnMount={ true }
anchor={ popoverAnchor }
className="block-editor-format-toolbar__image-popover"
>
<TextControl
className="block-editor-format-toolbar__image-container-value"
label={ __( 'Note' ) }
value={ note }
onChange={ ( newNote ) => {
const newReplacements = value.replacements.slice();

newReplacements[ value.start ] = {
tagName: 'data',
type: name,
attributes: {
...activeObjectAttributes,
note: newNote,
},
};

onChange( {
...value,
replacements: newReplacements,
} );
} }
/>
</Popover>
);
}
12 changes: 12 additions & 0 deletions packages/format-library/src/footnote/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.editor-styles-wrapper {
counter-reset: footnotes;
}

.note-link {
counter-increment: footnotes;
pointer-events: none;
}

.note-link::after {
content: "[" counter(footnotes) "]";
}
Loading