diff --git a/docs/manifest.json b/docs/manifest.json index e7d2a88e11cc5..e82a59211c392 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -899,6 +899,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/external-link/README.md", "parent": "components" }, + { + "title": "FocalPointPicker", + "slug": "focal-point-picker", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/focal-point-picker/README.md", + "parent": "components" + }, { "title": "FocusableIframe", "slug": "focusable-iframe", diff --git a/packages/block-library/src/cover/index.js b/packages/block-library/src/cover/index.js index b1ceb293c336f..1df4323902ecb 100644 --- a/packages/block-library/src/cover/index.js +++ b/packages/block-library/src/cover/index.js @@ -7,6 +7,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { + FocalPointPicker, IconButton, PanelBody, RangeControl, @@ -67,6 +68,9 @@ const blockAttributes = { type: 'string', default: 'image', }, + focalPoint: { + type: 'object', + }, }; export const name = 'core/cover'; @@ -175,6 +179,7 @@ export const settings = { backgroundType, contentAlign, dimRatio, + focalPoint, hasParallax, id, title, @@ -224,6 +229,10 @@ export const settings = { backgroundColor: overlayColor.color, }; + if ( focalPoint ) { + style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`; + } + const controls = ( @@ -265,6 +274,14 @@ export const settings = { onChange={ toggleParallax } /> ) } + { IMAGE_BACKGROUND_TYPE === backgroundType && ! hasParallax && ( + setAttributes( { focalPoint: value } ) } + /> + ) } { + const url = '/path/to/image'; + const dimensions = { + width: 400, + height: 100 + }; + return ( + setState( { focalPoint } ) } + /> + ) +} ); + +/* Example function to render the CSS styles based on Focal Point Picker value */ +const renderImageContainerWithFocalPoint = ( url, focalPoint ) => { + const style = { + backgroundImage: `url(${ url })` , + backgroundPosition: `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%` + } + return
; +}; +``` + +## Props + +### `url` + +- Type: `Text` +- Required: Yes +- Description: URL of the image to be displayed + +### `dimensions` + +- Type: `Object` +- Required: Yes +- Description: An object describing the height and width of the image. Requires two paramaters: `height`, `width`. + +### `value` + +- Type: `Object` +- Required: Yes +- Description: The focal point. Should be an object containing `x` and `y` params. + +### `onChange` + +- Type: `Function` +- Required: Yes +- Description: Callback which is called when the focal point changes. diff --git a/packages/components/src/focal-point-picker/index.js b/packages/components/src/focal-point-picker/index.js new file mode 100644 index 0000000000000..315c28f7303b7 --- /dev/null +++ b/packages/components/src/focal-point-picker/index.js @@ -0,0 +1,251 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, createRef } from '@wordpress/element'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BaseControl from '../base-control'; +import withFocusOutside from '../higher-order/with-focus-outside'; +import { Path, SVG } from '../primitives'; + +const TEXTCONTROL_MIN = 0; +const TEXTCONTROL_MAX = 100; + +export class FocalPointPicker extends Component { + constructor() { + super( ...arguments ); + this.onMouseMove = this.onMouseMove.bind( this ); + this.state = { + isDragging: false, + bounds: {}, + percentages: {}, + }; + this.containerRef = createRef(); + this.imageRef = createRef(); + this.horizontalPositionChanged = this.horizontalPositionChanged.bind( this ); + this.verticalPositionChanged = this.verticalPositionChanged.bind( this ); + this.onLoad = this.onLoad.bind( this ); + } + componentDidMount() { + this.setState( { + percentages: this.props.value, + } ); + } + componentDidUpdate( prevProps ) { + if ( prevProps.url !== this.props.url ) { + this.setState( { + isDragging: false, + } ); + } + } + calculateBounds() { + const bounds = { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }; + if ( ! this.imageRef.current ) { + return bounds; + } + const dimensions = { + width: this.imageRef.current.clientWidth, + height: this.imageRef.current.clientHeight, + }; + const pickerDimensions = this.pickerDimensions(); + const widthRatio = pickerDimensions.width / dimensions.width; + const heightRatio = pickerDimensions.height / dimensions.height; + if ( heightRatio >= widthRatio ) { + bounds.width = bounds.right = pickerDimensions.width; + bounds.height = dimensions.height * widthRatio; + bounds.top = ( pickerDimensions.height - bounds.height ) / 2; + bounds.bottom = bounds.top + bounds.height; + } else { + bounds.height = bounds.bottom = pickerDimensions.height; + bounds.width = dimensions.width * heightRatio; + bounds.left = ( pickerDimensions.width - bounds.width ) / 2; + bounds.right = bounds.left + bounds.width; + } + return bounds; + } + onLoad() { + this.setState( { + bounds: this.calculateBounds(), + } ); + } + onMouseMove( event ) { + const { isDragging, bounds } = this.state; + const { onChange } = this.props; + + if ( isDragging ) { + const pickerDimensions = this.pickerDimensions(); + const cursorPosition = { + left: event.pageX - pickerDimensions.left, + top: event.pageY - pickerDimensions.top, + }; + const left = Math.max( + bounds.left, + Math.min( + cursorPosition.left, bounds.right + ) + ); + const top = Math.max( + bounds.top, + Math.min( + cursorPosition.top, bounds.bottom + ) + ); + const percentages = { + x: ( left - bounds.left ) / ( pickerDimensions.width - ( bounds.left * 2 ) ), + y: ( top - bounds.top ) / ( pickerDimensions.height - ( bounds.top * 2 ) ), + }; + this.setState( { percentages }, function() { + onChange( { + x: this.state.percentages.x, + y: this.state.percentages.y, + } ); + } ); + } + } + fractionToPercentage( fraction ) { + return Math.round( fraction * 100 ); + } + horizontalPositionChanged( event ) { + this.positionChangeFromTextControl( 'x', event.target.value ); + } + verticalPositionChanged( event ) { + this.positionChangeFromTextControl( 'y', event.target.value ); + } + positionChangeFromTextControl( axis, value ) { + const { onChange } = this.props; + const { percentages } = this.state; + const cleanValue = Math.max( Math.min( parseInt( value ), 100 ), 0 ); + percentages[ axis ] = cleanValue ? cleanValue / 100 : 0; + this.setState( { percentages }, function() { + onChange( { + x: this.state.percentages.x, + y: this.state.percentages.y, + } ); + } ); + } + pickerDimensions() { + if ( this.containerRef.current ) { + return { + width: this.containerRef.current.clientWidth, + height: this.containerRef.current.clientHeight, + top: this.containerRef.current.getBoundingClientRect().top + document.body.scrollTop, + left: this.containerRef.current.getBoundingClientRect().left, + }; + } + return { + width: 0, + height: 0, + left: 0, + top: 0, + }; + } + handleFocusOutside() { + this.setState( { + isDragging: false, + } ); + } + render() { + const { instanceId, url, value, label, help, className } = this.props; + const { bounds, isDragging, percentages } = this.state; + const pickerDimensions = this.pickerDimensions(); + const iconCoordinates = { + left: ( value.x * ( pickerDimensions.width - ( bounds.left * 2 ) ) ) + bounds.left, + top: ( value.y * ( pickerDimensions.height - ( bounds.top * 2 ) ) ) + bounds.top, + }; + const iconContainerStyle = { + left: `${ iconCoordinates.left }px`, + top: `${ iconCoordinates.top }px`, + }; + const iconContainerClasses = classnames( + 'components-focal-point-picker__icon_container', + isDragging ? 'is-dragging' : null + ); + const id = `inspector-focal-point-picker-control-${ instanceId }`; + const horizontalPositionId = `inspector-focal-point-picker-control-horizontal-position-${ instanceId }`; + const verticalPositionId = `inspector-focal-point-picker-control-horizontal-position-${ instanceId }`; + return ( + +
+
this.setState( { isDragging: true } ) } + onDragStart={ () => this.setState( { isDragging: true } ) } + onMouseUp={ () => this.setState( { isDragging: false } ) } + onDrop={ () => this.setState( { isDragging: false } ) } + onMouseMove={ this.onMouseMove } + ref={ this.containerRef } + role="button" + tabIndex="-1" + > + Dimensions helper +
+ + + + +
+
+
+
+ + + % + + + + % + +
+
+ ); + } +} + +FocalPointPicker.defaultProps = { + url: null, + value: { + x: 0.5, + y: 0.5, + }, + onChange: () => {}, +}; + +export default compose( [ withInstanceId, withFocusOutside ] )( FocalPointPicker ); diff --git a/packages/components/src/focal-point-picker/style.scss b/packages/components/src/focal-point-picker/style.scss new file mode 100644 index 0000000000000..f93f2e57ad688 --- /dev/null +++ b/packages/components/src/focal-point-picker/style.scss @@ -0,0 +1,75 @@ +.components-focal-point-picker-wrapper { + background-color: transparent; + border: 1px solid $light-gray-500; + height: 200px; + width: 100%; + padding: 14px; +} + +.components-focal-point-picker { + align-items: center; + cursor: pointer; + display: flex; + height: 100%; + justify-content: center; + position: relative; + width: 100%; + + img { + height: auto; + max-height: 100%; + max-width: 100%; + width: auto; + user-select: none; + } +} + +.components-focal-point-picker__icon_container { + background-color: transparent; + cursor: grab; + height: 30px; + opacity: 0.8; + position: absolute; + will-change: transform; + width: 30px; + z-index: 10000; + + &.is-dragging { + cursor: grabbing; + } +} + +.components-focal-point-picker__icon { + display: block; + height: 100%; + left: -15px; + position: absolute; + top: -15px; + width: 100%; + + .components-focal-point-picker__icon-outline { + fill: $white; + } + + .components-focal-point-picker__icon-fill { + fill: theme(primary); + } +} + +.components-focal-point-picker_position-display-container { + margin: 1em 0; + display: flex; + + .components-base-control__field { + margin: 0 1em 0 0; + } + + input[type="number"].components-text-control__input { // Needs specificity to override padding. + max-width: 4em; + padding: 6px 4px; + } + + span { + margin: 0 0 0 0.2em; + } +} diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 415cd5a1be5dc..f892867c7a400 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -19,6 +19,7 @@ export { default as DropZoneProvider } from './drop-zone/provider'; export { default as Dropdown } from './dropdown'; export { default as DropdownMenu } from './dropdown-menu'; export { default as ExternalLink } from './external-link'; +export { default as FocalPointPicker } from './focal-point-picker'; export { default as FocusableIframe } from './focusable-iframe'; export { default as FontSizePicker } from './font-size-picker'; export { default as FormFileUpload } from './form-file-upload'; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index c3e8b38e7a7ef..046c7b6264efd 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -13,6 +13,7 @@ @import "./drop-zone/style.scss"; @import "./dropdown-menu/style.scss"; @import "./external-link/style.scss"; +@import "./focal-point-picker/style.scss"; @import "./font-size-picker/style.scss"; @import "./form-file-upload/style.scss"; @import "./form-toggle/style.scss";