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_

- _options_ `Object`: Preview options.
- _options.blocks_ `WPBlock[]`: Block objects.
- _options.props_ `Object`: Optional. Props to pass to the element. Must contain the ref if one is defined.
- _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
61 changes: 61 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,58 @@ 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} options Preview options.
* @param {WPBlock[]} options.blocks Block objects.
* @param {Object} options.props Optional. Props to pass to the element. Must contain
* the ref if one is defined.
* @param {Object} options.__experimentalLayout Layout settings to be used in the preview.
*
*/
export function useBlockPreview( {
blocks,
props = {},
__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
53 changes: 38 additions & 15 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,39 +14,49 @@ 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( {
__experimentalLayout,
postId,
postType,
userCanEdit,
} ) {
const [ , , content ] = useEntityProp(
'postType',
postType,
'content',
postId
);
const blockProps = useBlockProps();

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( {
blocks,
props: blockProps,
__experimentalLayout,
} );

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>
);
}

function EditableContent( { layout, context = {} } ) {
function EditableContent( { __experimentalLayout, context = {} } ) {
const { postType, postId } = context;
const themeSupportsLayout = useSelect( ( select ) => {
const { getSettings } = select( blockEditorStore );
return getSettings()?.supportsLayout;
}, [] );
const defaultLayout = useSetting( 'layout' ) || {};
const usedLayout = !! layout && layout.inherit ? defaultLayout : layout;
const [ blocks, onInput, onChange ] = useEntityBlockEditor(
'postType',
postType,
Expand All @@ -58,22 +69,34 @@ function EditableContent( { layout, context = {} } ) {
value: blocks,
onInput,
onChange,
__experimentalLayout: themeSupportsLayout ? usedLayout : undefined,
__experimentalLayout,
}
);
return <div { ...props } />;
}

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;

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

return isEditable ? (
<EditableContent { ...props } />
<EditableContent
{ ...props }
__experimentalLayout={ __experimentalLayout }
/>
) : (
<ReadOnlyContent
__experimentalLayout={ __experimentalLayout }
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