WordPress
wysiwyg
React
redux
Gutenberg

Wordpressの次世代エディタGutenbergを読む

More than 1 year has passed since last update.

WordPress5.0で導入されそうなエディタのGutenberg。React+Reduxで作られてると聞いたのでサラッと読んでみました。

https://github.com/WordPress/gutenberg

localhost_9004_wp-admin_post_php_post_1_action_edit.png


WordPressからReactAppの呼び出し

WP REST APIで該当記事のデータをJSONで取得して、それをwp_add_inline_scriptなどでHTMLに出力

https://github.com/WordPress/gutenberg/blob/f598c96810b682491fdfd3567c59378c5b65265f/lib/client-assets.php#L623-L648


client-assets.php

$post_to_edit = gutenberg_get_post_to_edit($post)

function gutenberg_get_post_to_edit( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'post_not_found', __( 'Post not found.', 'gutenberg' ) );
}
$post_type_object = get_post_type_object( $post->post_type );
if ( ! $post_type_object ) {
return new WP_Error( 'unrecognized_post_type', __( 'Unrecognized post type.', 'gutenberg' ) );
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error( 'unauthorized_post_type', __( 'Unauthorized post type.', 'gutenberg' ) );
}
$request = new WP_REST_Request(
'GET',
sprintf( '/wp/v2/%s/%d', ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name, $post->ID )
);
$request->set_param( 'context', 'edit' );
$response = rest_do_request( $request );
if ( $response->is_error() ) {
return $response->as_error();
}
return rest_get_server()->response_to_data( $response, false );
}


window空間内にデータを突っ込む。wp_add_inline_scriptでinlineのJSとして出力している。これはWPプラグインなどで良く見られる方法。

window空間にinitialStateを構築するデータを突っ込むのも定番だと思います。

https://github.com/WordPress/gutenberg/blob/f598c96810b682491fdfd3567c59378c5b65265f/lib/client-assets.php#L812-L816


client-assets.php

    wp_add_inline_script(

'wp-edit-post',
'window._wpGutenbergPost = ' . wp_json_encode( $post_to_edit ) . ';'
);

取得したデータを引数にしてinitializeEditorを実行。この関数がReactDOM.renderを呼び出している。

https://github.com/WordPress/gutenberg/blob/master/lib/client-assets.php#L894-L898


client-assets.php

window._wpLoadGutenbergEditor = wp.api.init().then( function() {

wp.blocks.registerCoreBlocks();
return wp[ 'edit-post' ].initializeEditor( 'editor', window._wpGutenbergPost, editorSettings );
})

initializeEditorはReactDOMを呼び出す感じのメソッドでedit-post/index.jsに定義されている様子。

この先はWebpackを使ってコンパイルされているJS。

window._wpGutenbergPostの中身を見るとcontentでHTMLをそのまま渡していることが分かります。

WP REST APIのpost属性に幾つかエディタに_linksなどの属性が付与されているっぽいです。

http://ja.wp-api.org/reference/posts/


window._wpGutenbergPost

{

"id":1,
"date":"2018-02-11T02:23:44",
"date_gmt":"2018-02-10T17:23:44",
"guid":{
"rendered":"http://localhost:9004/?p=1",
"raw":"http://localhost:9004/?p=1"
},
"modified":"2018-02-11T12:53:34",
"modified_gmt":"2018-02-11T03:53:34",
"password":"",
"slug":"hello-world",
"status":"draft",
"type":"post",
"link":"http://localhost:9004/?p=1",
"title":{
"raw":"Hello world!",
"rendered":"Hello world!"
},
"content":{
"raw":"<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>\n",
"rendered":"<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>\n",
"protected":false
},
"excerpt":{
"raw":"",
"rendered":"<p>WordPress へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>\n",
"protected":false
},
"author":1,
"featured_media":0,
"comment_status":"closed",
"ping_status":"closed",
"sticky":false,
"template":"",
"format":"standard",
"meta":[

],
"categories":[
1
],
"tags":[

],
"revisions":{
"count":14,
"last_id":18
},
"_links":{
"self":[
{
"href":"http://localhost:9004/index.php?rest_route=/wp/v2/posts/1"
}
],
"collection":[
{
"href":"http://localhost:9004/index.php?rest_route=/wp/v2/posts"
}
],
"about":[
{
"href":"http://localhost:9004/index.php?rest_route=/wp/v2/types/post"
}
],
"author":[
{
"embeddable":true,
"href":"http://localhost:9004/index.php?rest_route=/wp/v2/users/1"
}
],
"replies":[
{
"embeddable":true,
"href":"http://localhost:9004/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=1"
}
],
"version-history":[
{
"href":"http://localhost:9004/index.php?rest_route=/wp/v2/posts/1/revisions"
}
],
"wp:attachment":[
{
"href":"http://localhost:9004/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=1"
}
],
"wp:term":[
{
"taxonomy":"category",
"embeddable":true,
"href":"http://localhost:9004/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=1"
},
{
"taxonomy":"post_tag",
"embeddable":true,
"href":"http://localhost:9004/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=1"
}
],
"curies":[
{
"name":"wp",
"href":"https://api.w.org/{rel}",
"templated":true
}
]
}
}



editorを読んでいく


gutenbergのディレクトリ構成

以下の4つを抑えておくと読みやすいです。

edit-post - 管理画面上にmountされるReactSPA。

editor - edit-post内で呼び出されるエディタ
blocks - エディタ内で使用されるblock(後述)のコンポーネント群。
lib - Wordpressプラグインとして動くphpのコード


マウントとHTMLのパース

initializeEditorはedit-post/index.jsに定義されているので見てみます。

このedit-postディレクトリはWPの管理画面上にマウントされるReactSPAのディレクトリになっています。

initializeEditorを読むとよくあるReactのマウントを行うエントリーポイント的なメソッドになっていることがわかります。

ここのEditorProviderというのがeditorのstoreにpostを突っ込むactionをdispatchしてくれます。


edit-post/index.js

export function initializeEditor( id, post, settings ) {

const target = document.getElementById( id );
const reboot = reinitializeEditor.bind( null, target, settings );
const ReduxProvider = createProvider( 'edit-post' );

const provider = render(
<EditorProvider settings={ settings } post={ post }>
<ErrorBoundary onError={ reboot }>
<ReduxProvider store={ store }>
<Layout />
</ReduxProvider>
</ErrorBoundary>
</EditorProvider>,
target
);

return {
initializeMetaBoxes: provider.initializeMetaBoxes,
};
}


storeにpostを突っ込むというところで気になるのが「どのような形でstoreに保存しているか?」というところです。

保存する途中で resetBlocks( parse( post.content.raw ) )という気になるactionが発行されていました。

parseでは以下のようなObjectが得られます。

  {

uid: '22ae2359-031a-47a1-935b-7284404af3d3',
name: 'core/freeform',
isValid: true,
attributes: {
content: '<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>'
},
originalContent: '<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>'
}

これをstoreに格納する際にはuidでフラットなObjectになおして格納を行っています。

正規化してstoreに入れようというのは単一のオブジェクトであることを保証するためにDocsでも推奨されている方法ですね。

blocksByUidに実際のオブジェクトを、blockOrderで使用している順番にblockのuidを格納している感じでした。


store

  {

blocksByUid: {
'22ae2359-031a-47a1-935b-7284404af3d3': {
uid: '22ae2359-031a-47a1-935b-7284404af3d3',
name: 'core/freeform',
isValid: true,
attributes: {
content: '<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>'
},
originalContent: '<p><strong>WordPress</strong> へようこそ。これは最初の投稿です。編集もしくは削除してブログを始めてください !</p>'
}
},
blockOrder: [
'22ae2359-031a-47a1-935b-7284404af3d3'
]
}

とりあえず初期化処理としてReactをマウントしてHTMLをJS Objectとして表示する箇所まで読むことが出来ました。

blockというObjectをReactに読ませて対応するコンポーネントを表示していそうって予測がたちました。


blockの表示

とりあえず上から読んでいくとBlockList(editor/components/block-list/index.js)というコンポーネントがreduxとconnectする際にblocksをstoreから引っ張っていることがわかります。

ここからは駆け足。

https://github.com/WordPress/gutenberg/blob/45074119c4c94f6c53c119bbafe33eecf5ec0410/editor/components/block-list/index.js


editor/components/block-list/index.js

render() {

return map( layouts, ( layout ) => {
// When rendering grouped layouts, filter to blocks assigned to layout.
const layoutBlocks = isGroupedByLayout ?
filter( blocks, ( block ) => (
get( block, [ 'attributes', 'layout' ] ) === layout.name
) ) :
blocks;

return (
<BlockListLayout
key={ layout.name }
layout={ layout.name }
isGroupedByLayout={ isGroupedByLayout }
blocks={ layoutBlocks }
renderBlockMenu={ renderBlockMenu }
rootUID={ rootUID }
showContextualToolbar={ showContextualToolbar }
/>
);
} );
}



editor/components/block-list/layout.js

class BlockListLayout {

render() {
<BlockEdit
name={ blockName }
isSelected={ isSelected }
attributes={ block.attributes }
setAttributes={ this.setAttributes }
insertBlocksAfter={ isLocked ? undefined : this.insertBlocksAfter }
onReplace={ isLocked ? undefined : onReplace }
mergeBlocks={ isLocked ? undefined : this.mergeBlocks }
id={ block.uid }
isSelectionEnabled={ this.props.isSelectionEnabled }
toggleSelection={ this.props.toggleSelection }
/>
}


blocks/block-edit/index

export function BlockEdit( props ) {

const blockType = getBlockType( name );
const Edit = blockType.edit || blockType.save;

return (
<Edit
{ ...props }
className={ className }
focus={ props.isSelected ? {} : false }
setFocus={ noop }
/>
);
}


ここでblockTypeという要素が出てきます。getBlockTypeの中が気になるので見ていきます。


blocks/api/registration.js

export function getBlockType( name ) {

return blocks[ name ];
}

ここのblocksの中身を見てみましょう。globalに露出しているので、ChromeのDevToolsで直接見てみました。

スクリーンショット 2018-02-11 13.56.03.png

blockのリストっぽい配列が得られました。一つcore/freeformに対応するblockTypeを見ると以下の構造になっています。

ここのeditにはReactComponentが入っています。

{

"name":"core/freeform",
"title":"Classic",
"desription":"The classic editor, in block form.",
"icon":"editor-kitchensink",
"category":"formatting",
"edit": function(),
"attributes":{
"content":{
"type":"string",
"source":"html"
},
"className":{
"type":"string"
}
}
}

これでHTML=>JS Objectに変換。JS Objectにはblockの配列が入っていて、各blockに対して対応するblockTypeから表示に使用するReactComponentを呼びだしていることがわかりました。


所感

この設計はLPをドラッグ・アンド・ドロップで作成できるよ!ってサービスによくある設計だと思いました。

このBlockによって描写する仕組みに加えてエディタ特有のセレクション(カーソルの位置)やinline styleとかがついていきます。

なおリッチテキストが使える部分に関してはTinyMCEが入っており、エディタの難しい部分に関してはおまかせしているようでした。

今のselectionがどのブロックにあって、ブロックをまたいだ時にどういう扱いをするのかみたいなところが独特に見えました。

ちなみにblocksはユーザーがカスタマイズして追加出来る仕組みがあるので、こういった構造になっているのだと思います。

エディタ部分以外での学びがおもったより大きかった