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

Handle pasted block content #1331

Merged
merged 9 commits into from
Jul 5, 2017
Merged
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
1 change: 1 addition & 0 deletions blocks/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as query from './query';
export { query };
export { createBlock, switchToBlockType } from './factory';
export { default as parse } from './parser';
export { default as pasteHandler } from './paste';
export { default as serialize, getBlockDefaultClassname } from './serializer';
export { getCategories } from './categories';
export {
Expand Down
7 changes: 3 additions & 4 deletions blocks/api/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ import { createBlock } from './factory';
* Returns the block attributes parsed from raw content.
*
* @param {String} rawContent Raw block content
* @param {Object} blockType Block type
* @param {Object} attributes Block attribute matchers
* @return {Object} Block attributes
*/
export function parseBlockAttributes( rawContent, blockType ) {
const { attributes } = blockType;
export function parseBlockAttributes( rawContent, attributes ) {
if ( 'function' === typeof attributes ) {
return attributes( rawContent );
} else if ( attributes ) {
Expand Down Expand Up @@ -53,7 +52,7 @@ export function getBlockAttributes( blockType, rawContent, attributes ) {
attributes = {
...blockType.defaultAttributes,
...attributes,
...parseBlockAttributes( rawContent, blockType ),
...parseBlockAttributes( rawContent, blockType.attributes ),
};
}

Expand Down
106 changes: 106 additions & 0 deletions blocks/api/paste.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { nodeListToReact } from 'dom-react';
import { find, get } from 'lodash';

/**
* WordPress dependencies
*/
import { createElement } from 'element';

/**
* Internal dependencies
*/
import { createBlock } from './factory';
import { getBlockTypes, getUnknownTypeHandler } from './registration';
import { parseBlockAttributes } from './parser';

/**
* Normalises array nodes of any node type to an array of block level nodes.
* @param {Array} nodes Array of Nodes.
* @return {Array} Array of block level HTMLElements
*/
export function normaliseToBlockLevelNodes( nodes ) {
const decu = document.createDocumentFragment();
const accu = document.createDocumentFragment();

// A fragment is easier to work with.
nodes.forEach( node => decu.appendChild( node.cloneNode( true ) ) );

while ( decu.firstChild ) {
const node = decu.firstChild;

// Text nodes: wrap in a paragraph, or append to previous.
if ( node.nodeType === 3 ) {
if ( ! accu.lastChild || accu.lastChild.nodeName !== 'P' ) {
accu.appendChild( document.createElement( 'P' ) );
}

accu.lastChild.appendChild( node );
// Element nodes.
} else if ( node.nodeType === 1 ) {
// BR nodes: create a new paragraph on double, or append to previous.
if ( node.nodeName === 'BR' ) {
if ( node.nextSibling && node.nextSibling.nodeName === 'BR' ) {
accu.appendChild( document.createElement( 'P' ) );
decu.removeChild( node.nextSibling );
}

// Don't append to an empty paragraph.
if (
accu.lastChild &&
accu.lastChild.nodeName === 'P' &&
accu.lastChild.hasChildNodes()
) {
accu.lastChild.appendChild( node );
} else {
decu.removeChild( node );
}
} else if ( node.nodeName === 'P' ) {
// Only append non-empty paragraph nodes.
if ( /^(\s| )*$/.test( node.innerHTML ) ) {
decu.removeChild( node );
} else {
accu.appendChild( node );
}
} else {
accu.appendChild( node );
}
} else {
decu.removeChild( node );
}
}

return Array.from( accu.childNodes );
}

export default function( nodes ) {
return normaliseToBlockLevelNodes( nodes ).map( ( node ) => {
const block = getBlockTypes().reduce( ( acc, blockType ) => {
if ( acc ) {
return acc;
}

const transformsFrom = get( blockType, 'transforms.from', [] );
const transform = find( transformsFrom, ( { type } ) => type === 'raw' );

if ( ! transform || ! transform.matcher( node ) ) {
return acc;
}

const { name, defaultAttributes = [] } = blockType;
const attributes = parseBlockAttributes( node.outerHTML, transform.attributes );

return createBlock( name, { ...defaultAttributes, ...attributes } );
}, null );

if ( block ) {
return block;
}

return createBlock( getUnknownTypeHandler(), {
content: nodeListToReact( [ node ], createElement ),
} );
} );
}
2 changes: 1 addition & 1 deletion blocks/api/serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function serializeBlock( block ) {
const blockName = block.name;
const blockType = getBlockType( blockName );
const saveContent = getSaveContent( blockType, block.attributes );
const saveAttributes = getCommentAttributes( block.attributes, parseBlockAttributes( saveContent, blockType ) );
const saveAttributes = getCommentAttributes( block.attributes, parseBlockAttributes( saveContent, blockType.attributes ) );

if ( 'wp:core/more' === blockName ) {
return `<!-- more ${ saveAttributes.customText ? `${ saveAttributes.customText } ` : '' }-->${ saveAttributes.noTeaser ? '\n<!--noteaser-->' : '' }`;
Expand Down
26 changes: 11 additions & 15 deletions blocks/api/test/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,39 +33,35 @@ describe( 'block parser', () => {

describe( 'parseBlockAttributes()', () => {
it( 'should use the function implementation', () => {
const blockType = {
attributes: function( rawContent ) {
return {
content: rawContent + ' & Chicken',
};
},
const attributes = function( rawContent ) {
return {
content: rawContent + ' & Chicken',
};
};

expect( parseBlockAttributes( 'Ribs', blockType ) ).to.eql( {
expect( parseBlockAttributes( 'Ribs', attributes ) ).to.eql( {
content: 'Ribs & Chicken',
} );
} );

it( 'should use the query object implementation', () => {
const blockType = {
attributes: {
emphasis: text( 'strong' ),
ignoredDomMatcher: ( node ) => node.innerHTML,
},
const attributes = {
emphasis: text( 'strong' ),
ignoredDomMatcher: ( node ) => node.innerHTML,
};

const rawContent = '<span>Ribs <strong>& Chicken</strong></span>';

expect( parseBlockAttributes( rawContent, blockType ) ).to.eql( {
expect( parseBlockAttributes( rawContent, attributes ) ).to.eql( {
emphasis: '& Chicken',
} );
} );

it( 'should return an empty object if no attributes defined', () => {
const blockType = {};
const attributes = {};
const rawContent = '<span>Ribs <strong>& Chicken</strong></span>';

expect( parseBlockAttributes( rawContent, blockType ) ).to.eql( {} );
expect( parseBlockAttributes( rawContent, attributes ) ).to.eql( {} );
} );
} );

Expand Down
46 changes: 46 additions & 0 deletions blocks/api/test/paste.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it } from 'mocha';
import { equal } from 'assert';
import { JSDOM } from 'jsdom';

const { window } = new JSDOM();
const { document } = window;

import { normaliseToBlockLevelNodes } from '../paste';

describe( 'normaliseToBlockLevelNodes', () => {
function createNodes( HTML ) {
document.body.innerHTML = HTML;
return Array.from( document.body.childNodes );
}

function outerHTML( nodes ) {
return nodes.map( node => node.outerHTML ).join( '' );
}

function transform( HTML ) {
return outerHTML( normaliseToBlockLevelNodes( createNodes( HTML ) ) );
}

it( 'should convert double line breaks to paragraphs', () => {
equal( transform( 'test<br><br>test' ), '<p>test</p><p>test</p>' );
} );

it( 'should not convert single line break to paragraphs', () => {
equal( transform( 'test<br>test' ), '<p>test<br>test</p>' );
} );

it( 'should not add extra line at the start', () => {
equal( transform( 'test<br><br><br>test' ), '<p>test</p><p>test</p>' );
equal( transform( '<br>test<br><br>test' ), '<p>test</p><p>test</p>' );
} );

it( 'should preserve non-inline content', () => {
const HTML = '<p>test</p><div>test<br>test</div>';
equal( transform( HTML ), HTML );
} );

it( 'should remove empty paragraphs', () => {
equal( transform( '<p>&nbsp;</p>' ), '' );
} );
} );

39 changes: 37 additions & 2 deletions blocks/editable/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'element-closest';
* WordPress dependencies
*/
import { createElement, Component, renderToString } from 'element';
import { parse, pasteHandler } from '../api';
import { BACKSPACE, DELETE, ENTER } from 'utils/keycodes';

/**
Expand Down Expand Up @@ -52,6 +53,7 @@ export default class Editable extends Component {
this.onKeyUp = this.onKeyUp.bind( this );
this.changeFormats = this.changeFormats.bind( this );
this.onSelectionChange = this.onSelectionChange.bind( this );
this.onPastePostProcess = this.onPastePostProcess.bind( this );

this.state = {
formats: {},
Expand All @@ -77,6 +79,7 @@ export default class Editable extends Component {
editor.on( 'keydown', this.onKeyDown );
editor.on( 'keyup', this.onKeyUp );
editor.on( 'selectionChange', this.onSelectionChange );
editor.on( 'PastePostProcess', this.onPastePostProcess );

if ( this.props.onSetup ) {
this.props.onSetup( editor );
Expand Down Expand Up @@ -123,6 +126,38 @@ export default class Editable extends Component {
}
}

onPastePostProcess( event ) {
const childNodes = Array.from( event.node.childNodes );
const isBlockDelimiter = ( node ) =>
node.nodeType === 8 && /^ wp:/.test( node.nodeValue );
const isDoubleBR = ( node ) =>
node.nodeName === 'BR' && node.previousSibling && node.previousSibling.nodeName === 'BR';
const isBlockPart = ( node ) =>
isDoubleBR( node ) || this.editor.dom.isBlock( node );

// If there's no `onSplit` prop, content will later be converted to
// inline content.
if ( this.props.onSplit ) {
let blocks = [];

// Internal paste, so parse.
if ( childNodes.some( isBlockDelimiter ) ) {
blocks = parse( event.node.innerHTML.replace( /<meta[^>]+>/, '' ) );
// External paste with block level content, so attempt to assign
// blocks.
} else if ( childNodes.some( isBlockPart ) ) {
blocks = pasteHandler( childNodes );
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we could split out the logic here out of the Editable into the parse.js file. I mean the pasteHandler could probably check for the block delimiters itself, don't you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, I think part of this should be in Editable because it needs to decide what content it con handle itself (inline), and then pass the rest to the paste handler. This can probably be improved though. Maybe we should only do the black check and leave out the comment check.


if ( blocks.length ) {
// We must wait for TinyMCE to clean up paste containers after this
// event.
window.setTimeout( () => this.splitContent( blocks ), 0 );
event.preventDefault();
}
}
}

onChange() {
if ( ! this.editor.isDirty() ) {
return;
Expand Down Expand Up @@ -246,7 +281,7 @@ export default class Editable extends Component {
}
}

splitContent() {
splitContent( blocks = [] ) {
const { dom } = this.editor;
const rootNode = this.editor.getBody();
const beforeRange = dom.createRng();
Expand All @@ -266,7 +301,7 @@ export default class Editable extends Component {
const afterElement = nodeListToReact( afterFragment.childNodes, createTinyMCEElement );

this.setContent( beforeElement );
this.props.onSplit( beforeElement, afterElement );
this.props.onSplit( beforeElement, afterElement, ...blocks );
}

onNewBlock() {
Expand Down
19 changes: 14 additions & 5 deletions blocks/library/heading/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ registerBlockType( 'core/heading', {
} );
},
},
{
type: 'raw',
matcher: ( node ) => /H\d/.test( node.nodeName ),
attributes: {
content: children( 'h1,h2,h3,h4,h5,h6' ),
nodeName: prop( 'h1,h2,h3,h4,h5,h6', 'nodeName' ),
},
},
],
to: [
{
Expand All @@ -89,7 +97,7 @@ registerBlockType( 'core/heading', {
};
},

edit( { attributes, setAttributes, focus, setFocus, mergeBlocks, insertBlockAfter } ) {
edit( { attributes, setAttributes, focus, setFocus, mergeBlocks, insertBlocksAfter } ) {
const { align, content, nodeName = 'H2' } = attributes;

return [
Expand Down Expand Up @@ -142,11 +150,12 @@ registerBlockType( 'core/heading', {
onFocus={ setFocus }
onChange={ ( value ) => setAttributes( { content: value } ) }
onMerge={ mergeBlocks }
onSplit={ ( before, after ) => {
onSplit={ ( before, after, ...blocks ) => {
setAttributes( { content: before } );
insertBlockAfter( createBlock( 'core/text', {
content: after,
} ) );
insertBlocksAfter( [
...blocks,
createBlock( 'core/text', { content: after } ),
] );
} }
style={ { textAlign: align } }
placeholder={ __( 'Write heading…' ) }
Expand Down
17 changes: 17 additions & 0 deletions blocks/library/image/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ registerBlockType( 'core/image', {
caption: children( 'figcaption' ),
},

transforms: {
from: [
{
type: 'raw',
matcher: ( node ) => (
node.nodeName === 'IMG' ||
( ! node.textContent && node.querySelector( 'img' ) )
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we be stricter here? I'm afraid we'd use lose here. I=agin a div containing an img and several paragraphs. We'd lose everything but the img.

Copy link
Member Author

Choose a reason for hiding this comment

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

Doesn't node.textContent check for that?

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 I misread the condition.

),
attributes: {
url: attr( 'img', 'src' ),
alt: attr( 'img', 'alt' ),
caption: children( 'figcaption' ),
},
},
],
},

getEditWrapperProps( attributes ) {
const { align } = attributes;
if ( 'left' === align || 'right' === align || 'wide' === align || 'full' === align ) {
Expand Down
Loading