Skip to content

Commit 9ade57b

Browse files
committed
Adding initial metabox support.
Basic metabox support please see docs/metabox.md for details.
1 parent 23f8f05 commit 9ade57b

22 files changed

+2149
-7
lines changed

.eslintignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ build
22
coverage
33
vendor
44
node_modules
5+
/assets/js

assets/js/metabox.js

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
( function() {
2+
var observer;
3+
4+
if ( ! window.MutationObserver || ! document.getElementById( 'post' ) || ! window.parent ) {
5+
return;
6+
}
7+
8+
var previousWidth, previousHeight;
9+
10+
function sendResize() {
11+
var form = document.getElementById( 'post' );
12+
var location = form.dataset.location;
13+
var newWidth = form.scrollWidth;
14+
var newHeight = form.scrollHeight;
15+
16+
// Exit early if height has not been impacted.
17+
if ( newWidth === previousWidth && newHeight === previousHeight ) {
18+
return;
19+
}
20+
21+
window.parent.postMessage( {
22+
action: 'resize',
23+
source: 'metabox',
24+
location: location,
25+
width: newWidth,
26+
height: newHeight
27+
}, '*' );
28+
29+
previousWidth = newWidth;
30+
previousHeight = newHeight;
31+
}
32+
33+
observer = new MutationObserver( sendResize );
34+
observer.observe( document.getElementById( 'post' ), {
35+
attributes: true,
36+
attributeOldValue: true,
37+
characterData: true,
38+
characterDataOldValue: true,
39+
childList: true,
40+
subtree: true
41+
} );
42+
43+
window.addEventListener( 'load', sendResize, true );
44+
45+
sendResize();
46+
} )();

docs/metabox.md

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Metaboxes
2+
3+
This is a brief document detailing how metabox support works in Gutenberg. With
4+
the superior developer and user experience of blocks however, especially once,
5+
block templates are available, **converting PHP metaboxes to blocks is highly
6+
encouraged!**
7+
8+
## Breakdown
9+
10+
Each metabox area is rendered by a React component containing an iframe.
11+
Each iframe will render a partial page containing only metaboxes for that area.
12+
Metabox data is collected and used for conditional rendering.
13+
14+
### Metabox Data Collection
15+
16+
On each Gutenberg page load, the global state of post.php is mimicked, this is
17+
hooked in as far back as `plugins_loaded`.
18+
19+
See `lib/register.php gutenberg_trick_plugins_into_registering_metaboxes()`
20+
21+
This will register two new actions, one that fakes the global post state, and
22+
one that collects the metabox data to determine if an area is empty.
23+
24+
gutenberg_set_post_state() is hooked in early on admin_head to fake the post
25+
state. This is necessary for ACF to work, no other metabox frameworks seem to
26+
have this problem. ACF will grab the `$post->post_type` to determine whether a
27+
box should be registered. Later in `admin_head` ACF will register the metaboxes.
28+
29+
Hooked in later on admin_head is gutenberg_collect_metabox_data(), this will
30+
run through the functions and hooks that post.php runs to register metaboxes;
31+
namely `add_meta_boxes, add_meta_boxes_{$post->post_type}`, and `do_meta_boxes`.
32+
33+
A copy of the global $wp_meta_boxes is made then filtered through
34+
`apply_filters( 'filter_gutenberg_metaboxes', $_metaboxes_copy );`, which will
35+
strip out any core metaboxes along with standard custom taxonomy metaboxes.
36+
37+
Then each location for this particular type of metabox is checked for whether it
38+
is active. If it is not empty a value of true is stored, if it is empty a value
39+
of false is stored. This metabox location data is then dispatched by the editor
40+
Redux store in `INITIALIZE_METABOX_STATE`.
41+
42+
Ideally, this could be done at instantiation of the editor, and help simplify,
43+
this flow. However, it is not possible to know the metabox state before
44+
`admin_enqueue_scripts` where we are calling `createEditorInstance()`. This will
45+
have to do.
46+
47+
### Redux and React Metabox Management.
48+
49+
*The Redux store by default will hold all metaboxes as inactive*. When
50+
`INITIALIZE_METABOX_STATE` comes in, the store will update any active metabox
51+
areas by setting the `isActive` flag to `true`. Once this happens React will
52+
check for the new props sent in by Redux on the Metabox component. If that
53+
Metabox is now active, instead of rendering null, a MetaboxIframe component will
54+
be rendered. The Metabox component is the container component that mediates
55+
between the MetaboxIframe and the Redux Store. *If no metaboxes are active,
56+
nothing happens. This will be the default behavior, as all core metaboxes have
57+
been stripped.*
58+
59+
#### MetaboxIframe Component
60+
61+
When the component renders it will store a ref to the iframe, the component will
62+
set up a listener for post messages for resizing. assets/js/metabox.js is
63+
loaded inside the iframe and will send up postMessages for resizing, which the
64+
MetaboxIframe Component will use to manage its state. A mutation observer will
65+
also be created when the iframe loads. The observer will detect whether, any
66+
DOM changes have happened in the iframe, input and change event listeners will
67+
also be attached to check for changes.
68+
69+
The change detection will store the current form's `FormData`, then whenever a
70+
change is detected the current form data will be checked vs, the original form
71+
data. This serves as a way to see if the metabox state is dirty. When the
72+
metabox state has been detected to have changed, a Redux action
73+
`METABOX_STATE_CHANGED` is dispatched, updating the store setting the isDirty
74+
flag to `true`. If the state ever returns back to the original form data,
75+
`METABOX_STATE_CHANGED` is dispatched again to set the isDirty flag to `false`.
76+
A selector `isMetaboxStateDirty()` is used to help check whether the post can be
77+
updated. It checks each metabox for whether it is dirty, and if there is at
78+
least one dirty metabox, it will return true. This dirty detection does not
79+
impact creating new posts, as the content will have to change before metaboxes
80+
can trigger the overall dirty state.
81+
82+
When the post is updated, only metaboxes that are active and dirty, will be
83+
submitted. This removes any unnecessary requests being made. No extra revisions,
84+
are created either by the metabox submissions. A redux action will trigger on
85+
`REQUEST_POST_UPDATE` for any dirty metabox. See editor/effects.js. The
86+
`REQUEST_METABOX_UPDATE` action will set that metabox's state to isUpdating,
87+
the isUpdating prop will be sent into the MetaboxIframe and cause a form
88+
submission. After loading, the original change detection process is fired again
89+
to handle the new state. Double buffering the iframes here will improve the
90+
user experience quite a bit, as there currently is a flicker.
91+
92+
### Iframe serving a partial page.
93+
94+
Each iframe will point to an individual source. These are partial pages being
95+
served by post.php. Why this approach? By using post.php directly, we don't have
96+
to worry as much about getting the global state 100% correct for each and every
97+
use case of a metabox, especially when it comes to saving. Essentially, when
98+
post.php loads it will set up all of its state correctly, and when it hits the
99+
three `do_action( 'do_meta_boxes' )` hooks it will trigger our partial page.
100+
101+
`gutenberg_metabox_partial_page()` is used to render the metaboxes for a context
102+
then exit the execution thread early. A `metabox` request parameter is used to
103+
trigger this early exit. The metabox request parameter should match one of
104+
`'advanced'`, `'normal'`, or `'side'`. This value will determine which metabox
105+
area is served. So an example url would look like:
106+
107+
`mysite.com/wp-admin/post.php?post=1&action=edit&metabox=$location`
108+
109+
This url is automatically passed into React via a _wpMetaboxUrl global variable.
110+
The partial page is very similar to post.php and pretty much imitates it and
111+
after rendering the metaboxes via do_meta_boxes() it exits early and does some
112+
hook clean up. There are two extra files that are enqueued; both with a handle
113+
metabox-gutenberg. One is the js file from assets/js/metabox.js, which resizes
114+
the iframe. The stylesheet is generated by webpack from
115+
editor/metaboxes/metabox-iframe.scss and built
116+
into editor/build/metabox-iframe.css
117+
118+
These styles make use of some of the SASS variables, so that as the Gutenberg
119+
UI updates so will the Metaboxes.
120+
121+
The partial page mimics the post.php post form, so when it is submitted it will
122+
normally fire all of the necessary hooks and actions, and have the proper global
123+
state to correctly fire any PHP metabox mumbo jumbo without needing to modify
124+
any existing code. On successful submission the page will be reloaded back to
125+
the same partial page with updated data.
126+
127+
## Wrap Up.
128+
129+
There are some other details I am probably forgetting but this is a pretty good
130+
overview.

editor/actions.js

+66
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,72 @@ export function removeNotice( id ) {
317317
};
318318
}
319319

320+
// Metabox related actions.
321+
/**
322+
* Returns an action object used to check the state of metaboxes at a location.
323+
*
324+
* This should only be fired once to initialize meta box state. If a metabox
325+
* area is empty, this will set the store state to indicate that React should
326+
* not render the meta box area.
327+
*
328+
* Example: metaboxes = { side: true, normal: false }
329+
* This indicates that the sidebar has a metabox but the normal area does not.
330+
*
331+
* @param {Object} metaboxes Whether metabox locations are active.
332+
*
333+
* @return {Object} Action object
334+
*/
335+
export function initializeMetaboxState( metaboxes ) {
336+
return {
337+
type: 'INITIALIZE_METABOX_STATE',
338+
metaboxes,
339+
};
340+
}
341+
342+
/**
343+
* Returns an action object used to signify that a metabox finished reloading.
344+
*
345+
* @param {String} location Location of metabox: 'normal', 'sidebar'.
346+
*
347+
* @return {Object} Action object
348+
*/
349+
export function handleMetaboxReload( location ) {
350+
return {
351+
type: 'HANDLE_METABOX_RELOAD',
352+
location,
353+
};
354+
}
355+
356+
/**
357+
* Returns an action object used to request metabox update.
358+
*
359+
* @param {String} location Location of metabox: 'normal', 'sidebar'.
360+
*
361+
* @return {Object} Action object
362+
*/
363+
export function requestMetaboxUpdate( location ) {
364+
return {
365+
type: 'REQUEST_METABOX_UPDATE',
366+
location,
367+
};
368+
}
369+
370+
/**
371+
* Returns an action object used to set metabox state changed.
372+
*
373+
* @param {String} location Location of metabox: 'normal', 'sidebar'.
374+
* @param {Boolean} hasChanged Whether the metabox has changed.
375+
*
376+
* @return {Object} Action object
377+
*/
378+
export function metaboxStateChanged( location, hasChanged ) {
379+
return {
380+
type: 'METABOX_STATE_CHANGED',
381+
location,
382+
hasChanged,
383+
};
384+
}
385+
320386
export const createSuccessNotice = partial( createNotice, 'success' );
321387
export const createInfoNotice = partial( createNotice, 'info' );
322388
export const createErrorNotice = partial( createNotice, 'error' );

editor/effects.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ import {
2525
removeNotice,
2626
savePost,
2727
editPost,
28+
requestMetaboxUpdate,
2829
} from './actions';
2930
import {
3031
getCurrentPost,
3132
getCurrentPostType,
33+
getDirtyMetaboxes,
3234
getEditedPostContent,
3335
getPostEdits,
3436
isCurrentPostPublished,
@@ -86,7 +88,7 @@ export default {
8688
},
8789
REQUEST_POST_UPDATE_SUCCESS( action, store ) {
8890
const { previousPost, post } = action;
89-
const { dispatch } = store;
91+
const { dispatch, getState } = store;
9092

9193
const publishStatus = [ 'publish', 'private', 'future' ];
9294
const isPublished = publishStatus.indexOf( previousPost.status ) !== -1;
@@ -113,6 +115,10 @@ export default {
113115
) );
114116
}
115117

118+
// Update dirty metaboxes.
119+
const metaboxes = getDirtyMetaboxes( getState() );
120+
metaboxes.map( metabox => dispatch( requestMetaboxUpdate( metabox ) ) );
121+
116122
if ( get( window.history.state, 'id' ) !== post.id ) {
117123
window.history.replaceState(
118124
{ id: post.id },

editor/index.js

+12
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ const DEFAULT_SETTINGS = {
4141
maxWidth: 608,
4242
};
4343

44+
/**
45+
* Sadly we probably can not add this data directly into editor settings.
46+
*
47+
* ACF and other metaboxes need admin_head to fire for metabox registry.
48+
* admin_head fires after admin_enqueue_scripts which is where we create our
49+
* editor instance. If a cleaner solution can be imagined, please change
50+
* this, and try to get this data to load directly into the editor settings.
51+
*/
52+
4453
// Configure moment globally
4554
moment.locale( dateSettings.l10n.locale );
4655
if ( dateSettings.timezone.string ) {
@@ -63,6 +72,7 @@ if ( dateSettings.timezone.string ) {
6372
* @param {String} id Unique identifier for editor instance
6473
* @param {Object} post API entity for post to edit
6574
* @param {?Object} settings Editor settings object
75+
* @return {Object} The Redux store of the editor.
6676
*/
6777
export function createEditorInstance( id, post, settings ) {
6878
const store = createReduxStore();
@@ -149,4 +159,6 @@ export function createEditorInstance( id, post, settings ) {
149159
);
150160

151161
render( createEditorElement( <Layout /> ), target );
162+
163+
return store;
152164
}

editor/layout/index.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import UnsavedChangesWarning from '../unsaved-changes-warning';
2222
import DocumentTitle from '../document-title';
2323
import AutosaveMonitor from '../autosave-monitor';
2424
import { removeNotice } from '../actions';
25+
import Metabox from '../metaboxes';
2526
import {
2627
getEditorMode,
2728
isEditorSidebarOpened,
@@ -33,8 +34,8 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
3334
'is-sidebar-opened': isSidebarOpened,
3435
} );
3536

36-
return (
37-
<div className={ className }>
37+
return [
38+
<div key="editor" className={ className }>
3839
<DocumentTitle />
3940
<NoticeList onRemove={ props.removeNotice } notices={ notices } />
4041
<UnsavedChangesWarning />
@@ -48,8 +49,9 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
4849
<MetaBoxes />
4950
</div>
5051
{ isSidebarOpened && <Sidebar /> }
51-
</div>
52-
);
52+
</div>,
53+
<Metabox key="metaboxes" location="normal" isSidebarOpened={ isSidebarOpened } />,
54+
];
5355
}
5456

5557
export default connect(

0 commit comments

Comments
 (0)