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

Editable: separate out content ops and switch to using tinymce tree output #3713

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions blocks/api/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createElement } from '@wordpress/element';
/**
* External dependencies
*/
import { nodeListToReact, nodeToReact } from 'dom-react';
import { domreact } from '@wordpress/utils';
export { attr, prop, html, text, query } from 'hpq';

export const children = ( selector ) => {
Expand All @@ -18,7 +18,7 @@ export const children = ( selector ) => {
}

if ( match ) {
return nodeListToReact( match.childNodes || [], createElement );
return domreact.nodeListToReact( match.childNodes || [], createElement );
}

return [];
Expand All @@ -33,6 +33,6 @@ export const node = ( selector ) => {
match = domNode.querySelector( selector );
}

return nodeToReact( match, createElement );
return domreact.nodeToReact( match, createElement );
};
};
117 changes: 117 additions & 0 deletions blocks/editable/content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { renderToString } from '@wordpress/element';
import { last } from 'lodash';
import { childrenToReact } from './tree';

export function nodeListToReact( editor, nodeList ) {
const fragment = editor.getDoc().createDocumentFragment();

nodeList.forEach( function( node ) {
fragment.appendChild( node.cloneNode( true ) );
} );

return childrenToReact( editor.serializer.serialize( fragment, { format: 'tree' } ) );
}

function splitResult( before, after ) {
return { before: before, after: after };
}

export function setContent( editor, content ) {
if ( ! content ) {
content = '';
}

content = renderToString( content );
editor.setContent( content, { format: 'raw' } );
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use the "tree" format to set content as well?

Copy link
Author

Choose a reason for hiding this comment

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

Currently we only support getting the contents out as the tree but since it's an internal thing we just need to expose I see no reason why we couldn't support setting contents as a tree as well that would by pass the parsing step in the setContent call so it would be more efficient obviously.

Copy link
Member

Choose a reason for hiding this comment

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

That would be awesome, if we could send the same tree back 💯

}

export function getContent( editor ) {
return childrenToReact( editor.getContent( { format: 'tree' } ) );
Copy link
Contributor

Choose a reason for hiding this comment

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

What shape has this "tree" format? Can you share an example?

Do you think it makes sense to avoid the "react" element format at all and just use the tree format in the state as well (outside Editable), in which case, it requires some refactoring but might be worth it (same as #3048)

Copy link
Author

@spocke spocke Dec 4, 2017

Choose a reason for hiding this comment

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

I created a simple gist doc on the structure of this tree.
https://gist.github.com/spocke/b009c5ef460c188c8995eb46b064a6b7

I guess using it for tinymce related things makes sense but unsure if it's a general enough format for usage for other things since each node has methods on it for mutation. The react format seems simpler in that regard at the least it's input format.

Copy link
Member

Choose a reason for hiding this comment

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

Can you serialize this "tree" using JSON.stringify and recreate later after calling JSON.parse? The root cause why we don't want to store React objects in the Redux state is that rehydration doesn't work out of the box.

Copy link
Author

Choose a reason for hiding this comment

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

We could convert the tree into the simple json format that was suggested else where. It's pretty fast to just convert a one tree structure to another if it's just simple objects. We just need to implement a way to set the tree back in tiny just before the filters apply.

}

export function getSplitAtLine( editor ) {
const rootNode = editor.getBody();
const selectedNode = editor.selection.getNode();

if ( selectedNode.parentNode !== rootNode ) {
return null;
}

const dom = editor.dom;

if ( ! dom.isEmpty( selectedNode ) ) {
return null;
}

const childNodes = Array.from( rootNode.childNodes );
const index = dom.nodeIndex( selectedNode );
const beforeNodes = childNodes.slice( 0, index );
const afterNodes = childNodes.slice( index + 1 );
const beforeElement = nodeListToReact( editor, beforeNodes );
const afterElement = nodeListToReact( editor, afterNodes );

return splitResult( beforeElement, afterElement );
}

export function splitAtCaret( editor ) {
const { dom } = editor;
const rootNode = editor.getBody();
const beforeRange = dom.createRng();
const afterRange = dom.createRng();
const selectionRange = editor.selection.getRng();

beforeRange.setStart( rootNode, 0 );
beforeRange.setEnd( selectionRange.startContainer, selectionRange.startOffset );

afterRange.setStart( selectionRange.endContainer, selectionRange.endOffset );
afterRange.setEnd( rootNode, dom.nodeIndex( rootNode.lastChild ) + 1 );

const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();

const beforeElement = nodeListToReact( editor, beforeFragment.childNodes );
const afterElement = nodeListToReact( editor, afterFragment.childNodes );

return splitResult( beforeElement, afterElement );
}

export function splitAtBlock( editor ) {
// Getting the content before and after the cursor
const childNodes = Array.from( editor.getBody().childNodes );
let selectedChild = editor.selection.getStart();
while ( childNodes.indexOf( selectedChild ) === -1 && selectedChild.parentNode ) {
selectedChild = selectedChild.parentNode;
}
const splitIndex = childNodes.indexOf( selectedChild );
if ( splitIndex === -1 ) {
return null;
}
const beforeNodes = childNodes.slice( 0, splitIndex );
const lastNodeBeforeCursor = last( beforeNodes );
// Avoid splitting on single enter
if (
! lastNodeBeforeCursor ||
beforeNodes.length < 2 ||
!! lastNodeBeforeCursor.textContent
) {
return null;
}

const before = beforeNodes.slice( 0, beforeNodes.length - 1 );

// Removing empty nodes from the beginning of the "after"
// avoids empty paragraphs at the beginning of newly created blocks.
const after = childNodes.slice( splitIndex ).reduce( ( memo, node ) => {
if ( ! memo.length && ! node.textContent ) {
return memo;
}

memo.push( node );
return memo;
}, [] );

const beforeElement = nodeListToReact( editor, before );
const afterElement = nodeListToReact( editor, after );

return splitResult( beforeElement, afterElement );
}
134 changes: 21 additions & 113 deletions blocks/editable/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,20 @@
import tinymce from 'tinymce';
import classnames from 'classnames';
import {
last,
isEqual,
omitBy,
forEach,
merge,
identity,
find,
defer,
noop,
} from 'lodash';
import { nodeListToReact } from 'dom-react';
import 'element-closest';

/**
* WordPress dependencies
*/
import { createElement, Component, renderToString } from '@wordpress/element';
import { Component } from '@wordpress/element';
import { keycodes, createBlobURL } from '@wordpress/utils';
import { Slot, Fill } from '@wordpress/components';

Expand All @@ -34,31 +31,10 @@ import TinyMCE from './tinymce';
import { pickAriaProps } from './aria';
import patterns from './patterns';
import { EVENTS } from './constants';
import { getContent, setContent, getSplitAtLine, splitAtCaret, splitAtBlock } from './content';

const { BACKSPACE, DELETE, ENTER } = keycodes;

function createTinyMCEElement( type, props, ...children ) {
if ( props[ 'data-mce-bogus' ] === 'all' ) {
return null;
}

if ( props.hasOwnProperty( 'data-mce-bogus' ) ) {
return children;
}

return createElement(
type,
omitBy( props, ( value, key ) => key.indexOf( 'data-mce-' ) === 0 ),
...children
);
}

function isLinkBoundary( fragment ) {
return fragment.childNodes && fragment.childNodes.length === 1 &&
fragment.childNodes[ 0 ].nodeName === 'A' && fragment.childNodes[ 0 ].text.length === 1 &&
fragment.childNodes[ 0 ].text[ 0 ] === '\uFEFF';
}

function getFormatProperties( formatName, parents ) {
switch ( formatName ) {
case 'link' : {
Expand Down Expand Up @@ -524,30 +500,13 @@ export default class Editable extends Component {
return;
}

const rootNode = this.editor.getBody();
const selectedNode = this.editor.selection.getNode();
const split = getSplitAtLine( this.editor );

if ( selectedNode.parentNode !== rootNode ) {
return;
if ( split ) {
event.preventDefault();
this.setContent( split.before );
this.props.onSplit( split.before, split.after );
}

const dom = this.editor.dom;

if ( ! dom.isEmpty( selectedNode ) ) {
return;
}

event.preventDefault();

const childNodes = Array.from( rootNode.childNodes );
const index = dom.nodeIndex( selectedNode );
const beforeNodes = childNodes.slice( 0, index );
const afterNodes = childNodes.slice( index + 1 );
const beforeElement = nodeListToReact( beforeNodes, createTinyMCEElement );
const afterElement = nodeListToReact( afterNodes, createTinyMCEElement );

this.setContent( beforeElement );
this.props.onSplit( beforeElement, afterElement );
} else {
event.preventDefault();

Expand Down Expand Up @@ -581,27 +540,12 @@ export default class Editable extends Component {
* @param {Array} blocks The blocks to add after the split point.
*/
splitContent( blocks = [] ) {
const { dom } = this.editor;
const rootNode = this.editor.getBody();
const beforeRange = dom.createRng();
const afterRange = dom.createRng();
const selectionRange = this.editor.selection.getRng();

if ( rootNode.childNodes.length ) {
beforeRange.setStart( rootNode, 0 );
beforeRange.setEnd( selectionRange.startContainer, selectionRange.startOffset );

afterRange.setStart( selectionRange.endContainer, selectionRange.endOffset );
afterRange.setEnd( rootNode, dom.nodeIndex( rootNode.lastChild ) + 1 );

const beforeFragment = beforeRange.extractContents();
const afterFragment = afterRange.extractContents();

const beforeElement = nodeListToReact( beforeFragment.childNodes, createTinyMCEElement );
const afterElement = isLinkBoundary( afterFragment ) ? [] : nodeListToReact( afterFragment.childNodes, createTinyMCEElement );

this.setContent( beforeElement );
this.props.onSplit( beforeElement, afterElement, ...blocks );
const split = splitAtCaret( this.editor );
this.setContent( split.before );
this.props.onSplit( split.before, split.after, ...blocks );
} else {
this.setContent( [] );
this.props.onSplit( [], [], ...blocks );
Expand All @@ -613,47 +557,16 @@ export default class Editable extends Component {
return;
}

// Getting the content before and after the cursor
const childNodes = Array.from( this.editor.getBody().childNodes );
let selectedChild = this.editor.selection.getStart();
while ( childNodes.indexOf( selectedChild ) === -1 && selectedChild.parentNode ) {
selectedChild = selectedChild.parentNode;
}
const splitIndex = childNodes.indexOf( selectedChild );
if ( splitIndex === -1 ) {
return;
}
const beforeNodes = childNodes.slice( 0, splitIndex );
const lastNodeBeforeCursor = last( beforeNodes );
// Avoid splitting on single enter
if (
! lastNodeBeforeCursor ||
beforeNodes.length < 2 ||
!! lastNodeBeforeCursor.textContent
) {
return;
}

const before = beforeNodes.slice( 0, beforeNodes.length - 1 );
const split = splitAtBlock( this.editor );

// Removing empty nodes from the beginning of the "after"
// avoids empty paragraphs at the beginning of newly created blocks.
const after = childNodes.slice( splitIndex ).reduce( ( memo, node ) => {
if ( ! memo.length && ! node.textContent ) {
return memo;
}

memo.push( node );
return memo;
}, [] );

// Splitting into two blocks
this.setContent( this.props.value );

this.props.onSplit(
nodeListToReact( before, createTinyMCEElement ),
nodeListToReact( after, createTinyMCEElement )
);
if ( split ) {
// Splitting into two blocks
this.setContent( this.props.value );
this.props.onSplit(
split.before,
split.after
);
}
}

onNodeChange( { parents } ) {
Expand Down Expand Up @@ -683,16 +596,11 @@ export default class Editable extends Component {
}

setContent( content ) {
if ( ! content ) {
content = '';
}

content = renderToString( content );
this.editor.setContent( content, { format: 'raw' } );
setContent( this.editor, content );
}

getContent() {
return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement );
return getContent( this.editor );
}

updateFocus() {
Expand Down
46 changes: 46 additions & 0 deletions blocks/editable/tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createElement } from '@wordpress/element';
import { domreact } from '@wordpress/utils';

const ELEMENT_NODE = 1;
const TEXT_NODE = 3;

function attributesToReact( attributes ) {
const reactAttrs = {};

attributes.forEach( ( { name, value } ) => {
const canonicalKey = domreact.toCanonical( name );
const key = canonicalKey ? canonicalKey : name;
reactAttrs[ key ] = key === 'style' ? domreact.styleStringToJSON( value ) : value;
} );

return reactAttrs;
}

function elementToReact( node ) {
const props = node.attributes ? attributesToReact( node.attributes ) : {};
const children = node.firstChild ? childrenToReact( node ) : [];

return createElement( node.name, props, ...children );
}

function nodeToReact( node ) {
if ( ! node ) {
return null;
} else if ( node.type === ELEMENT_NODE ) {
return elementToReact( node );
} else if ( node.type === TEXT_NODE ) {
return node.value;
}

return null;
}

export function childrenToReact( node ) {
const children = [];

for ( let child = node.firstChild; child; child = child.next ) {
children.push( nodeToReact( child ) );
}

return children;
}
Loading