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

Try showing an alignment visualizer when using the drag handle to resize an image block #45056

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/experimental/editor-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ function gutenberg_enable_experiments() {
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-details-blocks', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableDetailsBlocks = true', 'before' );
}
if ( $gutenberg_experiments && array_key_exists( 'gutenberg-image-block-alignment-snapping', $gutenberg_experiments ) ) {
wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableImageBlockAlignmentSnapping = true', 'before' );
}
}

add_action( 'admin_init', 'gutenberg_enable_experiments' );
12 changes: 12 additions & 0 deletions lib/experiments-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ function gutenberg_initialize_experiments_settings() {
)
);

add_settings_field(
'gutenberg-image-block-alignment-snapping',
__( 'Image block snapping ', 'gutenberg' ),
'gutenberg_display_experiment_field',
'gutenberg-experiments',
'gutenberg_experiments_section',
array(
'label' => __( 'Test new guides for snapping to alignment sizes when resizing the image block.', 'gutenberg' ),
'id' => 'gutenberg-image-block-alignment-snapping',
)
);

register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* WordPress dependencies
*/
import { useThrottle } from '@wordpress/compose';
import { getScreenRect } from '@wordpress/dom';
import { createContext, useContext, useRef } from '@wordpress/element';

/**
* Internal dependencies
*/
import { getDistanceFromPointToEdge } from '../../utils/math';

const BlockAlignmentGuideContext = createContext( new Map() );
export const useBlockAlignmentGuides = () =>
useContext( BlockAlignmentGuideContext );

export function BlockAlignmentGuideContextProvider( { children } ) {
const guides = useRef( new Map() );

return (
<BlockAlignmentGuideContext.Provider value={ guides.current }>
{ children }
</BlockAlignmentGuideContext.Provider>
);
}

/**
* Detect whether the `element` is snapping to one of the alignment guide along its `snapEdge`.
*
* @param {Node} element The element to check for snapping.
* @param {'left'|'right'} snapEdge The edge that will snap.
* @param {Map} alignmentGuides A Map of alignment guide nodes.
* @param {number} snapGap The pixel threshold for snapping.
*
* @return {null|'none'|'wide'|'full'} The alignment guide or `null` if no snapping was detected.
*/
function detectSnapping( element, snapEdge, alignmentGuides, snapGap ) {
const elementRect = getScreenRect( element );

// Get a point on the resizable rect's edge for `getDistanceFromPointToEdge`.
// - Caveat: this assumes horizontal resizing.
const pointFromElementRect = {
x: elementRect[ snapEdge ],
y: elementRect.top,
};

let candidateGuide = null;

// Loop through alignment guide nodes.
alignmentGuides?.forEach( ( guide, name ) => {
const guideRect = getScreenRect( guide );

// Calculate the distance from the resizeable element's edge to the
// alignment zone's edge.
const distance = getDistanceFromPointToEdge(
pointFromElementRect,
guideRect,
snapEdge
);

// If the distance is within snapping tolerance, we are snapping to this alignment.
if ( distance < snapGap ) {
candidateGuide = name;
}
} );

return candidateGuide;
}

export function useDetectSnapping( {
snapGap = 50,
dwellTime = 300,
throttle = 100,
} = {} ) {
const alignmentGuides = useBlockAlignmentGuides();
const snappedAlignmentInfo = useRef();

return useThrottle( ( element, snapEdge ) => {
const snappedAlignment = detectSnapping(
element,
snapEdge,
alignmentGuides,
snapGap
);

// Set snapped alignment info when the user first reaches a snap guide.
if (
snappedAlignment &&
( ! snappedAlignmentInfo.current ||
snappedAlignmentInfo.current.name !== snappedAlignment )
) {
snappedAlignmentInfo.current = {
timestamp: Date.now(),
name: snappedAlignment,
};
}

// Unset snapped alignment info when the user moves away from a snap guide.
if ( ! snappedAlignment && snappedAlignmentInfo.current ) {
snappedAlignmentInfo.current = null;
return null;
}

// If the user hasn't dwelt long enough on the alignment, return early.
if (
snappedAlignmentInfo.current &&
Date.now() - snappedAlignmentInfo.current.timestamp < dwellTime
) {
return null;
}

const guide = alignmentGuides.get( snappedAlignmentInfo.current?.name );
if ( ! guide ) {
return null;
}

return {
name: snappedAlignment,
rect: getScreenRect( guide ),
};
}, throttle );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { useBlockAlignmentGuides } from './guide-context';

/**
* Renders hidden guide elements that are used for calculating snapping.
* Each guide has the same rect as a block would at the given alignment.
*
* @param {Object} props
* @param {string} props.contentSize The CSS value for content size (e.g. 600px).
* @param {string} props.wideSize The CSS value for wide size (e.g. 80%).
* @param {'none'|'wide'|'full'[]} props.alignments An array of the alignments to render.
* @param {'left'|'right'|'center'} props.justification The justification.
*/
export default function Guides( {
contentSize,
wideSize,
alignments,
justification,
} ) {
return (
<>
<style>
{ `
.block-editor-alignment-visualizer__guides {
--content-size: ${ contentSize ?? '0px' };
--wide-size: ${ wideSize ?? '0px' };

position: absolute;
width: 100%;
height: 100%;
}

.block-editor-alignment-visualizer__guide {
visibility: hidden;
position: absolute;
height: 100%;
margin: 0 auto;
}

.block-editor-alignment-visualizer__guide.none {
left: calc( ( 100% - var(--content-size) ) / 2 );
width: var(--content-size);
}

.block-editor-alignment-visualizer__guide.wide {
left: calc( ( 100% - var(--wide-size) ) / 2 );
width: var(--wide-size);
}

.block-editor-alignment-visualizer__guide.full {
width: 100%;
}

.is-content-justification-left .block-editor-alignment-visualizer__guide {
left: 0;
}
.is-content-justification-right .block-editor-alignment-visualizer__guide {
left: auto;
right: 0;
}
` }
</style>
<div
className={ classnames(
'block-editor-alignment-visualizer__guides',
{
[ `is-content-justification-${ justification }` ]:
justification,
}
) }
>
{ alignments.map( ( { name } ) => (
<Guide key={ name } alignment={ name } />
) ) }
</div>
</>
);
}

function Guide( { alignment } ) {
const guides = useBlockAlignmentGuides();
const updateGuideContext = useRefEffect(
( node ) => {
guides?.set( alignment, node );
return () => {
guides?.delete( alignment );
};
},
[ alignment ]
);

return (
<div
ref={ updateGuideContext }
className={ classnames(
'block-editor-alignment-visualizer__guide',
alignment
) }
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* WordPress dependencies
*/
import { getBlockSupport, hasBlockSupport } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
*/
import ShadowDOMContainer from './shadow-dom-container';
import Underlay from './underlay';
import { useLayout } from '../block-list/layout';
import useAvailableAlignments from '../block-alignment-control/use-available-alignments';
import { store as blockEditorStore } from '../../store';
import { getValidAlignments } from '../../hooks/align';
import Visualization from './visualization';
import Guides from './guides';

/**
* A component that displays block alignment guidelines.
*
* @param {Object} props
* @param {?string[]} props.allowedAlignments An optional array of alignments names. By default, the alignment support will be derived from the
* 'focused' block's block supports, but some blocks (image) have an ad-hoc alignment implementation.
* @param {string} props.focusedClientId The client id of the block to show the alignment guides for.
* @param {?string} props.highlightedAlignment The alignment name to show the label of.
*/
export default function BlockAlignmentVisualizer( {
allowedAlignments,
focusedClientId,
highlightedAlignment,
} ) {
const focusedBlockName = useSelect(
( select ) =>
select( blockEditorStore ).getBlockName( focusedClientId ),
[ focusedClientId ]
);

// Get the valid alignments of the focused block, or use the supplied `allowedAlignments`,
// which allows this to work for blocks like 'image' that don't use block supports.
const validAlignments =
allowedAlignments ??
getValidAlignments(
getBlockSupport( focusedBlockName, 'align' ),
hasBlockSupport( focusedBlockName, 'alignWide', true )
);
const availableAlignments = useAvailableAlignments( validAlignments );
const layout = useLayout();

if ( availableAlignments?.length === 0 ) {
talldan marked this conversation as resolved.
Show resolved Hide resolved
return null;
}

return (
<ShadowDOMContainer>
<Underlay
className="block-editor-alignment-visualizer"
focusedClientId={ focusedClientId }
>
<Visualization
alignments={ availableAlignments }
contentSize={ layout.contentSize }
wideSize={ layout.wideSize }
justification={ layout.justifyContent }
highlightedAlignment={ highlightedAlignment }
/>
<Guides
alignments={ availableAlignments }
contentSize={ layout.contentSize }
wideSize={ layout.wideSize }
justification={ layout.justifyContent }
/>
</Underlay>
</ShadowDOMContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';
import { createPortal, useState } from '@wordpress/element';

export default function ShadowDOMContainer( { children } ) {
const [ shadowRoot, setShadowRoot ] = useState( null );
const ref = useRefEffect( ( node ) => {
setShadowRoot( node.attachShadow( { mode: 'open' } ) );
return () => setShadowRoot( null );
}, [] );

return (
<div ref={ ref }>
{ shadowRoot && createPortal( children, shadowRoot ) }
</div>
);
}
Loading