Skip to content

Commit b623d2b

Browse files
committed
Adding initial metabox support.
Basic metabox support please see docs/metabox.md for details.
1 parent 15c515b commit b623d2b

26 files changed

+2429
-57
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/meta-box-resize.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: 'meta-box',
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/meta-box.md

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Meta Boxes
2+
3+
This is a brief document detailing how meta box support works in Gutenberg. With
4+
the superior developer and user experience of blocks however, especially once,
5+
block templates are available, **converting PHP meta boxes to blocks is highly
6+
encouraged!**
7+
8+
## Breakdown
9+
10+
Each meta box area is rendered by a React component containing an iframe.
11+
Each iframe will render a partial page containing only meta boxes for that area.
12+
Meta box data is collected and used for conditional rendering. The meta box areas
13+
will appear as toggle-able panels labeled "Extended Settings". More on this in
14+
the MetaBoxIframe component section.
15+
16+
### Meta Box 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_meta_boxes()`
22+
23+
This will register an action that collects the meta box data to determine if an
24+
area is empty. The original global state is reset upon collection of meta box
25+
data.
26+
27+
`gutenberg_collect_meta_box_data()` is hooked in later on `admin_head`. It will
28+
run through the functions and hooks that `post.php` runs to register meta boxes;
29+
namely `add_meta_boxes`, `add_meta_boxes_{$post->post_type}`, and `do_meta_boxes`.
30+
31+
A copy of the global `$wp_meta_boxes` is made then filtered through
32+
`apply_filters( 'filter_gutenberg_meta_boxes', $_meta_boxes_copy );`, which will
33+
strip out any core meta boxes along with standard custom taxonomy meta boxes.
34+
35+
Then each location for this particular type of meta box is checked for whether it
36+
is active. If it is not empty a value of true is stored, if it is empty a value
37+
of false is stored. This meta box location data is then dispatched by the editor
38+
Redux store in `INITIALIZE_META_BOX_STATE`.
39+
40+
Ideally, this could be done at instantiation of the editor and help simplify
41+
this flow. However, it is not possible to know the meta box state before
42+
`admin_enqueue_scripts`, where we are calling `createEditorInstance()`. This will
43+
have to do, unless we want to move `createEditorInstance()` to fire in the footer
44+
or at some point after `admin_head`. With recent changes to editor bootstrapping
45+
this might now be possible. Test with ACF to make sure.
46+
47+
### Redux and React Meta Box Management.
48+
49+
*The Redux store by default will hold all meta boxes as inactive*. When
50+
`INITIALIZE_META_BOX_STATE` comes in, the store will update any active meta box
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 meta boxes are active,
56+
nothing happens. This will be the default behavior, as all core meta boxes 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 to handle resizing. `assets/js/meta-box-resize.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 meta box state is dirty. When the
72+
meta box state has been detected to have changed, a Redux action
73+
`META_BOX_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+
`META_BOX_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 meta box for whether it is dirty, and if there is at
78+
least one dirty meta box, it will return true. This dirty detection does not
79+
impact creating new posts, as the content will have to change before meta boxes
80+
can trigger the overall dirty state.
81+
82+
When the post is updated, only meta boxes 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 meta box submissions. A Redux action will trigger on
85+
`REQUEST_POST_UPDATE` for any dirty meta box. See `editor/effects.js`. The
86+
`REQUEST_META_BOX_UPDATES` action will set that meta boxes' state to `isUpdating`,
87+
the `isUpdating` prop will be sent into the `MetaBoxIframe` and cause a form
88+
submission. The iframe will clone itself and perform a double buffer right
89+
before the main iframe submits its data. After loading, the original change
90+
detection process is fired again to handle the new state.
91+
92+
Since the meta box updating is being triggered on post save success, we check to
93+
see if the post is saving and display an updating overlay, to prevent users from
94+
changing the form values while the meta box is submitting. The saving overlay
95+
could be made transparent, to give a more seamless effect.
96+
97+
### Iframe serving a partial page.
98+
99+
Each iframe will point to an individual source. These are partial pages being
100+
served by post.php. Why this approach? By using post.php directly, we don't have
101+
to worry as much about getting the global state 100% correct for each and every
102+
use case of a meta box, especially when it comes to saving. Essentially, when
103+
post.php loads it will set up all of its state correctly, and when it hits the
104+
three `do_action( 'do_meta_boxes' )` hooks it will trigger our partial page.
105+
106+
When the new block editor was made into the default editor it is now required to
107+
provide the classic-editor flag to access the metabox partial page.
108+
109+
`gutenberg_meta_box_partial_page()` is used to render the meta boxes for a context
110+
then exit the execution thread early. A `meta_box` request parameter is used to
111+
trigger this early exit. The `meta_box` request parameter should match one of
112+
`'advanced'`, `'normal'`, or `'side'`. This value will determine which meta box
113+
area is served. So an example url would look like:
114+
115+
`mysite.com/wp-admin/post.php?post=1&action=edit&meta_box=$location&classic-editor`
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 meta boxes 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. One is the js file from `assets/js/meta-box-resize.js`, which resizes the iframe.
122+
The other is a stylesheet that is generated by webpack from `editor/meta-boxes/meta-box-iframe.scss`
123+
and built into `editor/build/meta-box-iframe.css`
124+
125+
These styles make use of some of the SASS variables, so that as the Gutenberg
126+
UI updates so will the meta boxes.
127+
128+
The partial page mimics the `post.php` post form, so when it is submitted it will
129+
normally fire all of the necessary hooks and actions, and have the proper global
130+
state to correctly fire any PHP meta box mumbo jumbo without needing to modify
131+
any existing code. On successful submission the page will be reloaded back to
132+
the same partial page with updated data. React will signal a `handleMetaBoxReload`
133+
to set up the new form state for dirty checking, remove the updating overlay,
134+
and set the store to no longer be updating the meta box area.

editor/actions.js

+65
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,71 @@ export function removeNotice( id ) {
408408
};
409409
}
410410

411+
/**
412+
* Returns an action object used to check the state of meta boxes at a location.
413+
*
414+
* This should only be fired once to initialize meta box state. If a meta box
415+
* area is empty, this will set the store state to indicate that React should
416+
* not render the meta box area.
417+
*
418+
* Example: metaBoxes = { side: true, normal: false }
419+
* This indicates that the sidebar has a meta box but the normal area does not.
420+
*
421+
* @param {Object} metaBoxes Whether meta box locations are active.
422+
*
423+
* @return {Object} Action object
424+
*/
425+
export function initializeMetaBoxState( metaBoxes ) {
426+
return {
427+
type: 'INITIALIZE_META_BOX_STATE',
428+
metaBoxes,
429+
};
430+
}
431+
432+
/**
433+
* Returns an action object used to signify that a meta box finished reloading.
434+
*
435+
* @param {String} location Location of meta box: 'normal', 'side'.
436+
*
437+
* @return {Object} Action object
438+
*/
439+
export function handleMetaBoxReload( location ) {
440+
return {
441+
type: 'HANDLE_META_BOX_RELOAD',
442+
location,
443+
};
444+
}
445+
446+
/**
447+
* Returns an action object used to request meta box update.
448+
*
449+
* @param {Array} locations Locations of meta boxes: ['normal', 'side' ].
450+
*
451+
* @return {Object} Action object
452+
*/
453+
export function requestMetaBoxUpdates( locations ) {
454+
return {
455+
type: 'REQUEST_META_BOX_UPDATES',
456+
locations,
457+
};
458+
}
459+
460+
/**
461+
* Returns an action object used to set meta box state changed.
462+
*
463+
* @param {String} location Location of meta box: 'normal', 'side'.
464+
* @param {Boolean} hasChanged Whether the meta box has changed.
465+
*
466+
* @return {Object} Action object
467+
*/
468+
export function metaBoxStateChanged( location, hasChanged ) {
469+
return {
470+
type: 'META_BOX_STATE_CHANGED',
471+
location,
472+
hasChanged,
473+
};
474+
}
475+
411476
export const createSuccessNotice = partial( createNotice, 'success' );
412477
export const createInfoNotice = partial( createNotice, 'info' );
413478
export const createErrorNotice = partial( createNotice, 'error' );

editor/effects.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ import {
2525
removeNotice,
2626
savePost,
2727
editPost,
28+
requestMetaBoxUpdates,
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,9 @@ export default {
113115
) );
114116
}
115117

118+
// Update dirty meta boxes.
119+
dispatch( requestMetaBoxUpdates( getDirtyMetaBoxes( getState() ) ) );
120+
116121
if ( get( window.history.state, 'id' ) !== post.id ) {
117122
window.history.replaceState(
118123
{ id: post.id },

editor/index.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { settings as dateSettings } from '@wordpress/date';
1616
import './assets/stylesheets/main.scss';
1717
import Layout from './layout';
1818
import EditorProvider from './provider';
19+
import { initializeMetaBoxState } from './actions';
1920

2021
// Configure moment globally
2122
moment.locale( dateSettings.l10n.locale );
@@ -45,17 +46,27 @@ window.jQuery( document ).on( 'heartbeat-tick', ( event, response ) => {
4546
/**
4647
* Initializes and returns an instance of Editor.
4748
*
49+
* The return value of this function is not necessary if we change where we
50+
* call createEditorInstance(). This is due to metaBox timing.
51+
*
4852
* @param {String} id Unique identifier for editor instance
4953
* @param {Object} post API entity for post to edit
5054
* @param {?Object} settings Editor settings object
55+
* @return {Object} Editor interface. Currently supports metabox initialization.
5156
*/
5257
export function createEditorInstance( id, post, settings ) {
5358
const target = document.getElementById( id );
5459

55-
render(
60+
const provider = render(
5661
<EditorProvider settings={ settings } post={ post }>
5762
<Layout />
5863
</EditorProvider>,
5964
target
6065
);
66+
67+
return {
68+
initializeMetaBoxes( metaBoxes ) {
69+
provider.store.dispatch( initializeMetaBoxState( metaBoxes ) );
70+
},
71+
};
6172
}

editor/layout/index.js

+3-3
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 MetaBoxes from '../meta-boxes';
2525
import {
2626
getEditorMode,
2727
isEditorSidebarOpened,
@@ -34,7 +34,7 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
3434
} );
3535

3636
return (
37-
<div className={ className }>
37+
<div key="editor" className={ className }>
3838
<DocumentTitle />
3939
<NoticeList onRemove={ props.removeNotice } notices={ notices } />
4040
<UnsavedChangesWarning />
@@ -45,7 +45,7 @@ function Layout( { mode, isSidebarOpened, notices, ...props } ) {
4545
{ mode === 'text' && <TextEditor /> }
4646
{ mode === 'visual' && <VisualEditor /> }
4747
</div>
48-
<MetaBoxes />
48+
<MetaBoxes location="normal" />
4949
</div>
5050
{ isSidebarOpened && <Sidebar /> }
5151
<Popover.Slot />

0 commit comments

Comments
 (0)