From 9a5326e80393d9dd411cc19c852d32b1cf2983a9 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 18 Aug 2017 09:44:05 -0400 Subject: [PATCH] Try: Framework-agnostic block interoperability ("Vanilla", Vue) --- blocks/api/serializer.js | 17 ++- blocks/library/index.js | 2 + blocks/library/vanilla-banner/index.js | 47 ++++++ blocks/library/vue-banner/index.js | 78 ++++++++++ .../visual-editor/block-render-context.js | 33 +++++ editor/modes/visual-editor/block.js | 8 +- element/index.js | 136 +++++++++++++++++- package.json | 3 +- 8 files changed, 312 insertions(+), 12 deletions(-) create mode 100644 blocks/library/vanilla-banner/index.js create mode 100644 blocks/library/vue-banner/index.js create mode 100644 editor/modes/visual-editor/block-render-context.js diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index c5aed9e25d3220..0e603d8339a15b 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -8,7 +8,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { Component, createElement, renderToString, cloneElement, Children } from '@wordpress/element'; +import { Component, createElement, buildVTree, renderToString, cloneElement, Children } from '@wordpress/element'; /** * Internal dependencies @@ -41,14 +41,21 @@ export function getSaveContent( blockType, attributes ) { if ( save.prototype instanceof Component ) { rawContent = createElement( save, { attributes } ); } else { - rawContent = save( { attributes } ); + const target = document.createElement( 'div' ); + rawContent = save( { attributes, target } ); - // Special-case function render implementation to allow raw HTML return - if ( 'string' === typeof rawContent ) { - return rawContent; + switch ( typeof rawContent ) { + // Special-case function render implementation for raw HTML return + case 'string': + return rawContent; + + case 'undefined': + return target.innerHTML; } } + rawContent = buildVTree( rawContent ); + // Adding a generic classname const addClassnameToElement = ( element ) => { if ( ! element || ! isObject( element ) || ! className ) { diff --git a/blocks/library/index.js b/blocks/library/index.js index ead7508fcef477..f10d206e35ac9f 100644 --- a/blocks/library/index.js +++ b/blocks/library/index.js @@ -23,3 +23,5 @@ import './text-columns'; import './verse'; import './video'; import './audio'; +import './vanilla-banner'; +import './vue-banner'; diff --git a/blocks/library/vanilla-banner/index.js b/blocks/library/vanilla-banner/index.js new file mode 100644 index 00000000000000..aeae6fee2dc64f --- /dev/null +++ b/blocks/library/vanilla-banner/index.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { registerBlockType, source } from '../../api'; + +const { text } = source; + +registerBlockType( 'core/vanilla-banner', { + title: __( 'Vanilla Banner' ), + + icon: 'marker', + + category: 'widgets', + + attributes: { + text: { + type: 'string', + source: text( 'h1' ), + default: 'Hello World', + }, + }, + + className: false, + + edit( { attributes, setAttributes } ) { + return [ 'div', + [ 'input', { + value: attributes.text, + onChange( event ) { + setAttributes( { + text: event.target.value, + } ); + }, + } ], + [ 'h1', attributes.text ], + ]; + }, + + save( { attributes } ) { + return [ 'h1', attributes.text ]; + }, +} ); diff --git a/blocks/library/vue-banner/index.js b/blocks/library/vue-banner/index.js new file mode 100644 index 00000000000000..282adb1de31a45 --- /dev/null +++ b/blocks/library/vue-banner/index.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import Vue from 'vue/dist/vue'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { registerBlockType, source } from '../../api'; + +const { text } = source; + +registerBlockType( 'core/vue-banner', { + title: __( 'Vue Banner' ), + + icon: 'marker', + + category: 'widgets', + + attributes: { + text: { + type: 'string', + source: text( 'h1' ), + default: 'Hello World', + }, + }, + + className: false, + + edit( { attributes, setAttributes, target } ) { + if ( target.firstChild ) { + Object.assign( target.firstChild.__vue__, attributes ); + return; + } + + const child = document.createElement( 'div' ); + target.appendChild( child ); + + new Vue( { + el: target.firstChild, + + data: () => ( { ...attributes } ), + + template: ` +
+ +

{{ text }}

+
+ `, + + methods: { + setText( nextText ) { + setAttributes( { text: nextText } ); + }, + }, + } ); + }, + + save( { attributes, target } ) { + const child = document.createElement( 'div' ); + target.appendChild( child ); + + new Vue( { + el: target.firstChild, + + data: () => ( { ...attributes } ), + + template: ` +

{{ text }}

+ `, + } ); + }, +} ); diff --git a/editor/modes/visual-editor/block-render-context.js b/editor/modes/visual-editor/block-render-context.js new file mode 100644 index 00000000000000..9b481b294e56ef --- /dev/null +++ b/editor/modes/visual-editor/block-render-context.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { buildVTree, renderSubtreeIntoContainer, Component } from '@wordpress/element'; + +class BlockRenderContext extends Component { + componentDidMount() { + this.delegatedRender(); + } + + componentDidUpdate() { + this.delegatedRender(); + } + + delegatedRender() { + const { render, ...props } = this.props; + const result = render( { ...props, target: this.node } ); + + if ( result ) { + renderSubtreeIntoContainer( + this, + buildVTree( result ), + this.node + ); + } + } + + render() { + return
this.node = node } />; + } +} + +export default BlockRenderContext; diff --git a/editor/modes/visual-editor/block.js b/editor/modes/visual-editor/block.js index e0f6e830e79ce5..0ccee65222a2f7 100644 --- a/editor/modes/visual-editor/block.js +++ b/editor/modes/visual-editor/block.js @@ -22,6 +22,7 @@ import { __, sprintf } from '@wordpress/i18n'; import InvalidBlockWarning from './invalid-block-warning'; import BlockCrashWarning from './block-crash-warning'; import BlockCrashBoundary from './block-crash-boundary'; +import BlockRenderContext from './block-render-context'; import BlockMover from '../../block-mover'; import BlockRightMenu from '../../block-settings-menu'; import BlockSwitcher from '../../block-switcher'; @@ -430,7 +431,8 @@ class VisualEditorBlock extends Component { > { isValid && ! error && ( - ) } - { ! isValid && ( + { /* TODO: Re-enable */ /* ! isValid && ( blockType.save( { attributes: block.attributes, className, } ) - ) } + ) */ }
{ !! error && } { ! isValid && } diff --git a/element/index.js b/element/index.js index 17770558a8e655..ab0d168b7abb0f 100644 --- a/element/index.js +++ b/element/index.js @@ -1,10 +1,138 @@ /** * External dependencies */ -import { createElement, Component, cloneElement, Children } from 'react'; -import { render, findDOMNode, unstable_createPortal } from 'react-dom'; // eslint-disable-line camelcase +import { createElement, Component, cloneElement, Children, isValidElement } from 'react'; +import { + render, + findDOMNode, + unstable_createPortal, // eslint-disable-line camelcase + unstable_renderSubtreeIntoContainer, // eslint-disable-line camelcase +} from 'react-dom'; import { renderToStaticMarkup } from 'react-dom/server'; -import { isString } from 'lodash'; +import { isString, castArray } from 'lodash'; + +const interops = [ + // { + // isHandled: ( element ) => ( + // element.$$typeof === Symbol.for( 'react.element' ) + // ), + // render( element, target ) { + // render( element, target ); + // }, + // }, + // { + // isHandled: ( [ tagName ] ) => ( + // tagName && tagName.prototype instanceof Component + // ), + // render( [ tagName, props, ...children ], target ) { + // render( + // createElement( tagName, props, children ), + // target + // ); + // }, + // }, + // { + // isHandled: ( [ VueComponent ] ) => VueComponent.prototype instanceof Vue, + // render( [ VueComponent, props, ...children ], target ) { + // if ( ! target.firstChild ) { + // const child = document.createElement( 'div' ); + // target.appendChild( child ); + // } + + // new Vue( { + // el: target.firstChild, + + // functional: true, + + // render( h ) { + // return h( VueComponent, { props }, children ); + // }, + // } ); + // }, + // }, +]; + +class InteropRenderer extends Component { + componentDidMount() { + this.props.handler.render( this.props.element, this.node ); + } + + componentWillReceiveProps( nextProps ) { + nextProps.handler.render( nextProps.element, this.node ); + } + + shouldComponentUpdate() { + return false; + } + + render() { + return createElement( 'div', { ref: ( node ) => this.node = node } ); + } +} + +export function buildVTree( element ) { + if ( null === element || undefined === element ) { + return null; + } + + if ( isValidElement( element ) ) { + return element; + } + + // Defer to interoperability handler + const handler = interops.find( ( interop ) => interop.isHandled( element ) ); + if ( handler ) { + return createElement( InteropRenderer, { + handler, + element, + } ); + } + + const [ type ] = element; + + // Handle React 16.x array render returns + const isNodeType = 'function' === typeof type || 'string' === typeof type; + if ( ! isNodeType ) { + return castArray( element ).map( buildVTree ); + } + + // Handle case where children starts at second argument + let [ , attributes, ...children ] = element; + if ( attributes && attributes.constructor !== Object ) { + children.unshift( attributes ); + attributes = null; + } + + children = castArray( children ).map( ( child ) => { + if ( 'boolean' === typeof child ) { + child = null; + } + + if ( null === child || undefined === child ) { + child = ''; + } else if ( 'number' === typeof child ) { + child = String( child ); + } + + if ( 'string' === typeof child ) { + return child; + } + + return buildVTree( child ); + } ); + + switch ( typeof type ) { + case 'string': + return createElement( type, attributes, ...children ); + + case 'function': + return buildVTree( type( { ...attributes, children } ) ); + } +} + +export function wpRender( element, target ) { + render( buildVTree( element ), null, target ); +} /** * Returns a new element of given type. Type can be either a string tag name or @@ -61,6 +189,8 @@ export { Children }; */ export { unstable_createPortal as createPortal }; // eslint-disable-line camelcase +export { unstable_renderSubtreeIntoContainer as renderSubtreeIntoContainer }; // eslint-disable-line camelcase + /** * Renders a given element into a string * diff --git a/package.json b/package.json index c9d6de94448399..eebb82038361d8 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "refx": "^2.0.0", "rememo": "^2.3.0", "simple-html-tokenizer": "^0.4.1", - "uuid": "^3.0.1" + "uuid": "^3.0.1", + "vue": "^2.4.2" }, "devDependencies": { "autoprefixer": "^6.7.7",