Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Post Content Block: Render readonly content as blocks to preserve block supports styles #35863

17 changes: 17 additions & 0 deletions packages/block-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,23 @@ _Returns_

Undocumented declaration.

### useBlockPreview

This hook is used to lightly mark an element as a block preview wrapper
element. Call this hook and pass the returned props to the element to mark as
a block preview wrapper, automatically rendering inner blocks as children. If
you define a ref for the element, it is important to pass the ref to this
hook, which the hook in turn will pass to the component through the props it
returns. Optionally, you can also pass any other props through this hook, and
they will be merged and returned.

_Parameters_

- _props_ `Object`: Optional. Props to pass to the element. Must contain the ref if one is defined.
- _options_ `Object`: Preview options.
- _options.blocks_ `WPBlock[]`: Block objects.
- _options.\_\_experimentalLayout_ `Object`: Layout settings to be used in the preview.

### useBlockProps

This hook is used to lightly mark an element as a block element. The element
Expand Down
60 changes: 60 additions & 0 deletions packages/block-editor/src/components/block-preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
* External dependencies
*/
import { castArray } from 'lodash';
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import {
__experimentalUseDisabled as useDisabled,
useMergeRefs,
} from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { memo, useMemo } from '@wordpress/element';

Expand All @@ -16,6 +21,7 @@ import BlockEditorProvider from '../provider';
import LiveBlockPreview from './live';
import AutoHeightBlockPreview from './auto';
import { store as blockEditorStore } from '../../store';
import { BlockListItems } from '../block-list';

export function BlockPreview( {
blocks,
Expand Down Expand Up @@ -63,3 +69,57 @@ export function BlockPreview( {
* @return {WPComponent} The component to be rendered.
*/
export default memo( BlockPreview );

/**
* This hook is used to lightly mark an element as a block preview wrapper
* element. Call this hook and pass the returned props to the element to mark as
* a block preview wrapper, automatically rendering inner blocks as children. If
* you define a ref for the element, it is important to pass the ref to this
* hook, which the hook in turn will pass to the component through the props it
* returns. Optionally, you can also pass any other props through this hook, and
* they will be merged and returned.
*
* @param {Object} props Optional. Props to pass to the element. Must contain
gziolo marked this conversation as resolved.
Show resolved Hide resolved
* the ref if one is defined.
* @param {Object} options Preview options.
* @param {WPBlock[]} options.blocks Block objects.
* @param {Object} options.__experimentalLayout Layout settings to be used in the preview.
*
*/
export function useBlockPreview(
props = {},
{ blocks, __experimentalLayout }
) {
const originalSettings = useSelect(
( select ) => select( blockEditorStore ).getSettings(),
[]
);
const disabledRef = useDisabled();
const ref = useMergeRefs( [ props.ref, disabledRef ] );
const settings = useMemo( () => {
const _settings = { ...originalSettings };
_settings.__experimentalBlockPatterns = [];
return _settings;
}, [ originalSettings ] );
const renderedBlocks = useMemo( () => castArray( blocks ), [ blocks ] );

const children = (
<BlockEditorProvider value={ renderedBlocks } settings={ settings }>
<BlockListItems
renderAppender={ false }
__experimentalLayout={ __experimentalLayout }
/>
</BlockEditorProvider>
);

return {
...props,
ref,
className: classnames(
props.className,
'block-editor-block-preview__live-content',
'components-disabled'
),
children: blocks?.length ? children : null,
};
}
23 changes: 23 additions & 0 deletions packages/block-editor/src/components/block-preview/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,26 @@
}
}
}

.block-editor-block-preview__live-content {
* {
pointer-events: none;
}

// Hide the block appender, as the block is not editable in this context.
.block-list-appender {
display: none;
}

// Revert button disable styles to ensure that button styles render as they will on the
// front end of the site. For example, this ensures that Social Links icons display correctly.
.components-button:disabled {
opacity: initial;
}

// Hide placeholders.
.components-placeholder,
.block-editor-block-list__block[data-empty="true"] {
display: none;
}
}
2 changes: 1 addition & 1 deletion packages/block-editor/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export { default as BlockList } from './block-list';
export { useBlockProps } from './block-list/use-block-props';
export { LayoutStyle as __experimentalLayoutStyle } from './block-list/layout';
export { default as BlockMover } from './block-mover';
export { default as BlockPreview } from './block-preview';
export { default as BlockPreview, useBlockPreview } from './block-preview';
export {
default as BlockSelectionClearer,
useBlockSelectionClearer as __unstableUseBlockSelectionClearer,
Expand Down
31 changes: 25 additions & 6 deletions packages/block-library/src/post-content/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { parse } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
import { RawHTML } from '@wordpress/element';
import {
useBlockPreview,
useBlockProps,
useInnerBlocksProps,
useSetting,
Expand All @@ -13,28 +14,45 @@ import {
Warning,
} from '@wordpress/block-editor';
import { useEntityProp, useEntityBlockEditor } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';

/**
* Internal dependencies
*/
import { useCanEditEntity } from '../utils/hooks';

function ReadOnlyContent( { userCanEdit, postType, postId } ) {
function ReadOnlyContent( { layout, userCanEdit, postType, postId } ) {
const [ , , content ] = useEntityProp(
'postType',
postType,
'content',
postId
);
const blockProps = useBlockProps();

const themeSupportsLayout = useSelect( ( select ) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since now both EditableContent and ReadOnlyContent use the layout we can remove this logic from both and add it only to Content.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I've moved the layout logic up to the Content component 👍

const { getSettings } = select( blockEditorStore );
return getSettings()?.supportsLayout;
}, [] );
const defaultLayout = useSetting( 'layout' ) || {};
const usedLayout = !! layout && layout.inherit ? defaultLayout : layout;

const rawContent = content?.raw;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm little hesitant with this change from content.rendered to content.raw. I think that this might lead to security issues with private content.. @peterwilsoncc can you share your thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct: previews need to use the rendered version as the raw version may not be available to all users. For example authors don't have raw rights for others' posts, contributors for any published post.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for raising this @ntsekouras and for the feedback @peterwilsoncc! In the use case in this PR, unfortunately I don't think it'll work to use content.rendered as we need the raw markup of blocks in order to be able to parse them and have the styles be rendered properly by the preview.

As a bit of background, this PR is an alternative / workaround for the issue of rendering block supports styles on the server in order to display styles correctly in the editor. We currently have no way of retrieving the block supports styles from the server via an API request, so the next best option in this PR was to allow the editor to generate the styles from the block markup.

In the case of the Post Content block, in most cases, I imagine that users will see this block in the editor when editing template parts rather than in an isolated post. Because author and contributor roles don't have access to the site editor, in practice, I'm wondering if it isn't too much of an issue if we have the Post Content preview depend on access to content.raw. We already only render the blocks if content.raw is available, otherwise this falls back to rendering an empty preview, so on balance I think it could be more beneficial to prioritise the correct rendering for the admin view of the block, rather than focusing on author/contributor roles being able to view it in the editor.

const blocks = useMemo( () => {
return rawContent ? parse( rawContent ) : [];
}, [ rawContent ] );

const blockPreviewProps = useBlockPreview( blockProps, {
blocks,
__experimentalLayout: themeSupportsLayout ? usedLayout : undefined,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the logic for the layout something that could be moved to useBlockPreview? You could potentially read the layout attribute from the block edit context.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, useBlockProps can be called inside useBlockPreview, too. We might need the same hook for Comment Content block one day so I'm thinking about how to make this hook easier to use in other places.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a neat idea — I think for the moment, I'm leaning toward keeping this particular layout logic outside of the useBlockPreview hook, in case it's possible for us to use the hook in other circumstances where useBlockProps isn't appropriate. I'm thinking of arbitrary block previews that are used elsewhere in the repo, for example in the block switcher or for block patterns. They currently use the BlockPreview component directly, but it could be good to retain some flexibility for the hook in case we wanted to roll it out there, (or internally in the BlockPreview component itself?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely right. I didn't think about the original use case of <BlockPreview /> that was hidden in the changes for this PR. I guess the usage of the preview inside the edit is rather an edge case so we shouldn't expect that the block edit context is set here 👍

} );

return content?.protected && ! userCanEdit ? (
<div { ...blockProps }>
<Warning>{ __( 'This content is password protected.' ) }</Warning>
</div>
) : (
<div { ...blockProps }>
<RawHTML key="html">{ content?.rendered }</RawHTML>
</div>
<div { ...blockPreviewProps }></div>
);
}

Expand Down Expand Up @@ -65,7 +83,7 @@ function EditableContent( { layout, context = {} } ) {
}

function Content( props ) {
const { context: { queryId, postType, postId } = {} } = props;
const { context: { queryId, postType, postId } = {}, layout } = props;
const isDescendentOfQueryLoop = !! queryId;
const userCanEdit = useCanEditEntity( 'postType', postType, postId );
const isEditable = userCanEdit && ! isDescendentOfQueryLoop;
Expand All @@ -74,6 +92,7 @@ function Content( props ) {
<EditableContent { ...props } />
) : (
<ReadOnlyContent
layout={ layout }
userCanEdit={ userCanEdit }
postType={ postType }
postId={ postId }
Expand Down
92 changes: 92 additions & 0 deletions packages/compose/src/hooks/use-disabled/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* External dependencies
*/
import { includes, debounce } from 'lodash';

/**
* WordPress dependencies
*/
import { useCallback, useLayoutEffect, useRef } from '@wordpress/element';
import { focus } from '@wordpress/dom';

/**
* Names of control nodes which qualify for disabled behavior.
*
* See WHATWG HTML Standard: 4.10.18.5: "Enabling and disabling form controls: the disabled attribute".
*
* @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute
*
* @type {string[]}
*/
const DISABLED_ELIGIBLE_NODE_NAMES = [
'BUTTON',
'FIELDSET',
'INPUT',
'OPTGROUP',
'OPTION',
'SELECT',
'TEXTAREA',
];

export default function useDisabled() {
ntsekouras marked this conversation as resolved.
Show resolved Hide resolved
/** @type {import('react').RefObject<HTMLDivElement>} */
const node = useRef( null );

const disable = () => {
if ( ! node.current ) {
return;
}

focus.focusable.find( node.current ).forEach( ( focusable ) => {
if (
includes( DISABLED_ELIGIBLE_NODE_NAMES, focusable.nodeName )
) {
focusable.setAttribute( 'disabled', '' );
}

if ( focusable.nodeName === 'A' ) {
focusable.setAttribute( 'tabindex', '-1' );
}

const tabIndex = focusable.getAttribute( 'tabindex' );
if ( tabIndex !== null && tabIndex !== '-1' ) {
focusable.removeAttribute( 'tabindex' );
}

if ( focusable.hasAttribute( 'contenteditable' ) ) {
focusable.setAttribute( 'contenteditable', 'false' );
}
} );
};

// Debounce re-disable since disabling process itself will incur
// additional mutations which should be ignored.
const debouncedDisable = useCallback(
debounce( disable, undefined, { leading: true } ),
[]
);

useLayoutEffect( () => {
disable();

/** @type {MutationObserver | undefined} */
let observer;
if ( node.current ) {
observer = new window.MutationObserver( debouncedDisable );
observer.observe( node.current, {
childList: true,
attributes: true,
subtree: true,
} );
}

return () => {
if ( observer ) {
observer.disconnect();
}
debouncedDisable.cancel();
};
}, [] );

return node;
}
1 change: 1 addition & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbin
export { default as useCopyOnClick } from './hooks/use-copy-on-click';
export { default as useCopyToClipboard } from './hooks/use-copy-to-clipboard';
export { default as __experimentalUseDialog } from './hooks/use-dialog';
export { default as __experimentalUseDisabled } from './hooks/use-disabled';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as useFocusOnMount } from './hooks/use-focus-on-mount';
export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
Expand Down