Skip to content

Commit af47feb

Browse files
committed
Adding initial metabox support.
Basic metabox support please see docs/metabox.md for details.
1 parent a596ceb commit af47feb

27 files changed

+2555
-91
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

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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. The metaboxe areas
13+
will appear as toggle-able panels labeled "Extended Settings". More on this in
14+
the MetaboxIframe component section.
15+
16+
### Metabox Data Collection
17+
18+
On each Gutenberg page load, the global state of post.php is mimicked, this is
19+
hooked in as far back as `plugins_loaded`.
20+
21+
See `lib/register.php gutenberg_trick_plugins_into_registering_metaboxes()`
22+
23+
This will register two new actions, one that fakes the global post state, and
24+
one that collects the metabox data to determine if an area is empty. The
25+
original global state is reset upon collection of metabox data.
26+
27+
gutenberg_set_post_state() is hooked in early on admin_head to fake the post
28+
state. This is necessary for ACF to work, no other metabox frameworks seem to
29+
have this problem. ACF will grab the `$post->post_type` to determine whether a
30+
box should be registered. Later in `admin_head` ACF will register the metaboxes.
31+
32+
Hooked in later on admin_head is gutenberg_collect_metabox_data(), this will
33+
run through the functions and hooks that post.php runs to register metaboxes;
34+
namely `add_meta_boxes, add_meta_boxes_{$post->post_type}`, and `do_meta_boxes`.
35+
36+
A copy of the global $wp_meta_boxes is made then filtered through
37+
`apply_filters( 'filter_gutenberg_metaboxes', $_metaboxes_copy );`, which will
38+
strip out any core metaboxes along with standard custom taxonomy metaboxes.
39+
40+
Then each location for this particular type of metabox is checked for whether it
41+
is active. If it is not empty a value of true is stored, if it is empty a value
42+
of false is stored. This metabox location data is then dispatched by the editor
43+
Redux store in `INITIALIZE_METABOX_STATE`.
44+
45+
Ideally, this could be done at instantiation of the editor, and help simplify,
46+
this flow. However, it is not possible to know the metabox state before
47+
`admin_enqueue_scripts` where we are calling `createEditorInstance()`. This will
48+
have to do.
49+
50+
### Redux and React Metabox Management.
51+
52+
*The Redux store by default will hold all metaboxes as inactive*. When
53+
`INITIALIZE_METABOX_STATE` comes in, the store will update any active metabox
54+
areas by setting the `isActive` flag to `true`. Once this happens React will
55+
check for the new props sent in by Redux on the Metabox component. If that
56+
Metabox is now active, instead of rendering null, a MetaboxIframe component will
57+
be rendered. The Metabox component is the container component that mediates
58+
between the MetaboxIframe and the Redux Store. *If no metaboxes are active,
59+
nothing happens. This will be the default behavior, as all core metaboxes have
60+
been stripped.*
61+
62+
#### MetaboxIframe Component
63+
64+
When the component renders it will store a ref to the iframe, the component will
65+
set up a listener for post messages to handle resizing. assets/js/metabox.js is
66+
loaded inside the iframe and will send up postMessages for resizing, which the
67+
MetaboxIframe Component will use to manage its state. A mutation observer will
68+
also be created when the iframe loads. The observer will detect whether, any
69+
DOM changes have happened in the iframe, input and change event listeners will
70+
also be attached to check for changes.
71+
72+
The change detection will store the current form's `FormData`, then whenever a
73+
change is detected the current form data will be checked vs, the original form
74+
data. This serves as a way to see if the metabox state is dirty. When the
75+
metabox state has been detected to have changed, a Redux action
76+
`METABOX_STATE_CHANGED` is dispatched, updating the store setting the isDirty
77+
flag to `true`. If the state ever returns back to the original form data,
78+
`METABOX_STATE_CHANGED` is dispatched again to set the isDirty flag to `false`.
79+
A selector `isMetaboxStateDirty()` is used to help check whether the post can be
80+
updated. It checks each metabox for whether it is dirty, and if there is at
81+
least one dirty metabox, it will return true. This dirty detection does not
82+
impact creating new posts, as the content will have to change before metaboxes
83+
can trigger the overall dirty state.
84+
85+
When the post is updated, only metaboxes that are active and dirty, will be
86+
submitted. This removes any unnecessary requests being made. No extra revisions,
87+
are created either by the metabox submissions. A redux action will trigger on
88+
`REQUEST_POST_UPDATE` for any dirty metabox. See editor/effects.js. The
89+
`REQUEST_METABOX_UPDATE` action will set that metabox's state to isUpdating,
90+
the isUpdating prop will be sent into the MetaboxIframe and cause a form
91+
submission. After loading, the original change detection process is fired again
92+
to handle the new state. The iframe will clone itself and perform a double
93+
buffer right before the main iframe submits its data.
94+
95+
Since the metabox updating is being triggered on post save success, we check to
96+
see if the post is saving and display an updating overlay, to prevent users from
97+
changing the form values while the metabox is submitting. The saving overlay
98+
could be made transparent, to give a more seamless effect.
99+
100+
### Iframe serving a partial page.
101+
102+
Each iframe will point to an individual source. These are partial pages being
103+
served by post.php. Why this approach? By using post.php directly, we don't have
104+
to worry as much about getting the global state 100% correct for each and every
105+
use case of a metabox, especially when it comes to saving. Essentially, when
106+
post.php loads it will set up all of its state correctly, and when it hits the
107+
three `do_action( 'do_meta_boxes' )` hooks it will trigger our partial page.
108+
109+
`gutenberg_metabox_partial_page()` is used to render the metaboxes for a context
110+
then exit the execution thread early. A `metabox` request parameter is used to
111+
trigger this early exit. The metabox request parameter should match one of
112+
`'advanced'`, `'normal'`, or `'side'`. This value will determine which metabox
113+
area is served. So an example url would look like:
114+
115+
`mysite.com/wp-admin/post.php?post=1&action=edit&metabox=$location`
116+
117+
This url is automatically passed into React via a _wpMetaboxUrl global variable.
118+
The partial page is very similar to post.php and pretty much imitates it and
119+
after rendering the metaboxes via do_meta_boxes() it imitates admin_footer,
120+
exits early, and does some hook clean up. There are two extra files that are
121+
enqueued; both with a handle metabox-gutenberg. One is the js file from
122+
`assets/js/metabox.js`, which resizes the iframe. The stylesheet is generated by
123+
webpack from `editor/metaboxes/metabox-iframe.scss` and built into
124+
`editor/build/metabox-iframe.css`
125+
126+
These styles make use of some of the SASS variables, so that as the Gutenberg
127+
UI updates so will the Metaboxes.
128+
129+
The partial page mimics the post.php post form, so when it is submitted it will
130+
normally fire all of the necessary hooks and actions, and have the proper global
131+
state to correctly fire any PHP metabox mumbo jumbo without needing to modify
132+
any existing code. On successful submission the page will be reloaded back to
133+
the same partial page with updated data. React will signal a handleMetaboxReload
134+
to set up the new form state for dirty checking, remove the updating overlay,
135+
and set the store to no longer be updating the metabox area.
136+
137+
## Wrap Up.
138+
139+
There are some other details I am probably forgetting but this is a pretty good
140+
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.forEach( 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-6
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import Header from '../header';
1717
import Sidebar from '../sidebar';
1818
import TextEditor from '../modes/text-editor';
1919
import VisualEditor from '../modes/visual-editor';
20-
import MetaBoxes from '../meta-boxes';
2120
import UnsavedChangesWarning from '../unsaved-changes-warning';
2221
import DocumentTitle from '../document-title';
2322
import AutosaveMonitor from '../autosave-monitor';
2423
import { removeNotice } from '../actions';
24+
import Metabox from '../metaboxes';
2525
import {
2626
getEditorMode,
2727
isEditorSidebarOpened,
@@ -33,8 +33,8 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
3333
'is-sidebar-opened': isSidebarOpened,
3434
} );
3535

36-
return (
37-
<div className={ className }>
36+
return [
37+
<div key="editor" className={ className }>
3838
<DocumentTitle />
3939
<NoticeList onRemove={ props.removeNotice } notices={ notices } />
4040
<UnsavedChangesWarning />
@@ -45,11 +45,11 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
4545
{ mode === 'text' && <TextEditor /> }
4646
{ mode === 'visual' && <VisualEditor /> }
4747
</div>
48-
<MetaBoxes />
48+
<Metabox key="metaboxes" location="normal" isSidebarOpened={ isSidebarOpened } />
4949
</div>
5050
{ isSidebarOpened && <Sidebar /> }
51-
</div>
52-
);
51+
</div>,
52+
];
5353
}
5454

5555
export default connect(

0 commit comments

Comments
 (0)