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

Focal Point Picker #10925

Merged
merged 22 commits into from
Jan 29, 2019
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
684e09b
Focal Point Picker, initial commit. Cover block implementation of Foc…
jeffersonrabb Oct 22, 2018
397ea7b
Focal Point Picker README, initial commit.
jeffersonrabb Oct 22, 2018
2d6d651
Fixes for Cover blocks that pre-date the Focal Point Picker: 1) suppr…
jeffersonrabb Oct 22, 2018
4958adc
Small mistake in sample code.
jeffersonrabb Oct 22, 2018
b121c90
README edits.
jeffersonrabb Oct 22, 2018
e019989
Missing file.
jeffersonrabb Nov 12, 2018
021888c
Include Focal Point Picker stylesheet in packages/components/src/styl…
jeffersonrabb Jan 16, 2019
ded1017
Some conflict resolution in cover block's index.js.
jeffersonrabb Jan 16, 2019
97fcd04
Regrouping of component dependencies.
jeffersonrabb Jan 18, 2019
7131d07
Display horizontal and vertical positions as percentages in text boxe…
jeffersonrabb Jan 18, 2019
5567d16
Alternate approach to getting image dimensions, without requiring tha…
jeffersonrabb Jan 18, 2019
7c7f605
Removing dimensions attribute from Cover block.
jeffersonrabb Jan 18, 2019
491ede0
Fix to bug that displayed NaN/NaN in position fields before target is…
jeffersonrabb Jan 18, 2019
8bd268d
Make text input fields for horizontal and vertical position editable.
jeffersonrabb Jan 18, 2019
b67aaa4
Completely replace background image with IMG tag approach.
jeffersonrabb Jan 19, 2019
2a1d14e
Calculate bounds only when image onLoad fires.
jeffersonrabb Jan 19, 2019
48d4942
Replace TextControl elements with plain inputs. Percentage sign after…
jeffersonrabb Jan 19, 2019
87b63db
Adding a bit of padding around the image, so the white space that occ…
jeffersonrabb Jan 19, 2019
1163fe0
Small a11y and visual fixes
Jan 21, 2019
1bd8ea0
Use an SVG, and theme the colors
Jan 21, 2019
b44f1b9
CSS tweak to avoid visual artifacts in Safari during dragging.
jeffersonrabb Jan 21, 2019
d61d15a
Changed "component" prefix for all classes to "components" to match c…
jeffersonrabb Jan 21, 2019
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
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,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",
Expand Down
30 changes: 30 additions & 0 deletions packages/block-library/src/cover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import {
FocalPointPicker,
IconButton,
PanelBody,
RangeControl,
Expand Down Expand Up @@ -67,6 +68,12 @@ const blockAttributes = {
type: 'string',
default: 'image',
},
focalPoint: {
type: 'object',
},
dimensions: {
type: 'object',
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need to store the dimensions? Can't we just use percentage in the focal point instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The focal point picker needs image dimensions for two reasons:

  • To define the drag area for the target. The user should only be able to drag the target over the area of the image, and unless the image's aspect ratio precisely matches the focal point picker's area there will be blank space on the left/right or top/bottom. Awareness of the dimensions allows a precise definition of the drag area. This is done here:
    calculateBounds() {
    const { dimensions } = this.props;
    const pickerDimensions = this.pickerDimensions();
    const bounds = {
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    width: 0,
    height: 0,
    };
    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;
    }
  • To convert the location of the target to a percentage-based focal point we need to know the dimensions of the original image. This calculation occurs here:
    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
    )
    );
    onChange( {
    x: left / pickerDimensions.width,
    y: top / pickerDimensions.height,
    } );
    }
    }

However, I don't love requiring dimensions as a parameter for the component. First of all, it's restrictive - ideally the component just takes the image path and handles the rest. And second, it also creates a deprecation problem for Cover blocks created prior to the Focal Point Picker in which the dimensions are not retained. Ideally, I'd prefer that the Focal Point Picker component determine the dimensions programmatically from the image itself. I'm going to experiment with a few approaches to this and will update the PR shortly.

Copy link
Contributor Author

@jeffersonrabb jeffersonrabb Jan 18, 2019

Choose a reason for hiding this comment

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

In 5567d16 I've introduced a different approach to getting the image dimensions. The parameters are gone. Instead, the component creates an invisible <img /> (opacity: 0) and derives the dimensions from that. This way the block using the component need not supply dimensions and the component will work with legacy blocks. Does this seem like a viable approach?

Update: as of b67aaa4 I've removed the background image completely in favor of the IMG element, so it is no longer invisible as I indicated above.

};

export const name = 'core/cover';
Expand Down Expand Up @@ -175,6 +182,8 @@ export const settings = {
backgroundType,
contentAlign,
dimRatio,
dimensions,
focalPoint,
hasParallax,
id,
title,
Expand Down Expand Up @@ -209,6 +218,10 @@ export const settings = {
url: media.url,
id: media.id,
backgroundType: mediaType,
dimensions: {
height: media.height,
width: media.width,
},
} );
};
const toggleParallax = () => setAttributes( { hasParallax: ! hasParallax } );
Expand All @@ -224,6 +237,10 @@ export const settings = {
backgroundColor: overlayColor.color,
};

if ( focalPoint ) {
style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`;
}

const controls = (
<Fragment>
<BlockControls>
Expand Down Expand Up @@ -265,6 +282,15 @@ export const settings = {
onChange={ toggleParallax }
/>
) }
{ IMAGE_BACKGROUND_TYPE === backgroundType && ! hasParallax && (
<FocalPointPicker
label={ __( 'Focal Point Picker' ) }
url={ url }
dimensions={ dimensions }
value={ focalPoint }
onChange={ ( value ) => setAttributes( { focalPoint: value } ) }
/>
) }
<PanelColorSettings
title={ __( 'Overlay' ) }
initialOpen={ true }
Expand Down Expand Up @@ -370,6 +396,7 @@ export const settings = {
contentAlign,
customOverlayColor,
dimRatio,
focalPoint,
hasParallax,
overlayColor,
title,
Expand All @@ -382,6 +409,9 @@ export const settings = {
if ( ! overlayColorClass ) {
style.backgroundColor = customOverlayColor;
}
if ( focalPoint && ! hasParallax ) {
style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`;
}

const classes = classnames(
dimRatioToClass( dimRatio ),
Expand Down
68 changes: 68 additions & 0 deletions packages/components/src/focal-point-picker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Focal Point Picker

Focal Point Picker is a component which creates a UI for identifying the most important visual point of an image. This component addresses a specific problem: with large background images it is common to see undesirable crops, especially when viewing on smaller viewports such as mobile phones. This component allows the selection of the point with the most important visual information and returns it as a pair of numbers between 0 and 1. This value can be easily converted into the CSS `background-position` attribute, and will ensure that the focal point is never cropped out, regardless of viewport.

Example focal point picker value: `{ x: 0.5, y: 0.1 }`
Corresponding CSS: `background-position: 50% 10%;`

## Usage

```jsx
import { FocalPointPicker } from '@wordpress/components';

const MyFocalPointPicker = withState( {
focalPoint: {
x: 0.5,
y: 0.5
},
} )( ( { focalPoint, setState } ) => {
const url = '/path/to/image';
Copy link
Member

Choose a reason for hiding this comment

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

This example is going to be used in auto-generated docs so it should ideally provide a valid url that can be rendered.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are there examples of valid url parameters in any other Gutenberg components? I just did a quick scan looking for one and came up blank, but maybe you can point me to a relevant one?

Copy link
Member

Choose a reason for hiding this comment

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

const dimensions = {
width: 400,
height: 100
};
return (
<FocalPointPicker
url={ url }
dimensions={ dimensions }
value={ focalPoint }
onChange={ ( focalPoint ) => setState( { focalPoint } ) }
/>
)
} );

/* Example function to render the CSS styles based on Focal Point Picker value */
const renderImageContainerWithFocalPoint = ( url, focalPoint ) => {
Copy link
Member

Choose a reason for hiding this comment

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

How would this function be used? It’s not clear from this document.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is meant to illustrate how the percentage values from the component can be translated into a "real-world" CSS background-position definition. The relevant line in Cover block is:

style.backgroundPosition = `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`;

It would probably make sense for me to use the actual example instead of the hypothetical, but I'd prefer to hold off on updating the README until the PR is ready to merge, in case there are changes still to come. Would this be a reasonable approach?

const style = {
backgroundImage: `url(${ url })` ,
backgroundPosition: `${ focalPoint.x * 100 }% ${ focalPoint.y * 100 }%`
}
return <div style={ style } />;
};
```

## 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.
159 changes: 159 additions & 0 deletions packages/components/src/focal-point-picker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* External dependencies
*/

import { Component, createRef } from '@wordpress/element';
import { withInstanceId, compose } from '@wordpress/compose';
import BaseControl from '../base-control/';
import withFocusOutside from '../higher-order/with-focus-outside';
import classnames from 'classnames';
Copy link
Member

Choose a reason for hiding this comment

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

Only classnames is an external dependency here.

Packages with the WordPress namespace should be in their own group.

Everything else is internal dependencies.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Resolved in 97fcd04


export class FocalPointPicker extends Component {
constructor() {
super( ...arguments );
this.onMouseMove = this.onMouseMove.bind( this );
this.state = {
isDragging: false,
bounds: {},
};
this.containerRef = createRef();
}
componentDidMount() {
this.setState( { bounds: this.calculateBounds() } );
}
componentDidUpdate( prevProps ) {
if ( prevProps.dimensions !== this.props.dimensions || prevProps.url !== this.props.url ) {
this.setState( {
bounds: this.calculateBounds(),
isDragging: false,
} );
}
}
calculateBounds() {
const { dimensions } = this.props;
const pickerDimensions = this.pickerDimensions();
const bounds = {
top: 0,
left: 0,
bottom: 0,
right: 0,
width: 0,
height: 0,
};
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;
}
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
)
);
onChange( {
x: left / pickerDimensions.width,
y: top / pickerDimensions.height,
} );
}
}
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,
};
}
render() {
const { instanceId, url, value, label, help, className } = this.props;
const { isDragging } = this.state;
const pickerDimensions = this.pickerDimensions();
const containerStyle = { backgroundImage: `url(${ url })` };
const iconContainerStyle = {
left: `${ value.x * pickerDimensions.width }px`,
top: `${ value.y * pickerDimensions.height }px`,
};
const iconContainerClasses = classnames(
'component-focal-point-picker__icon_container',
jeffersonrabb marked this conversation as resolved.
Show resolved Hide resolved
isDragging ? 'is-dragging' : null
);
const id = `inspector-focal-point-picker-control-${ instanceId }`;
return (
<BaseControl label={ label } id={ id } help={ help } className={ className }>
<div
className="component-focal-point-picker"
style={ containerStyle }
onMouseDown={ () => 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="0"
>
<div className={ iconContainerClasses } style={ iconContainerStyle }>
<i className="component-focal-point-picker__icon"></i>
</div>
</div>
</BaseControl>
);
}
handleFocusOutside() {
jeffersonrabb marked this conversation as resolved.
Show resolved Hide resolved
this.setState( {
isDragging: false,
} );
}
}

FocalPointPicker.defaultProps = {
url: null,
dimensions: {
height: 0,
width: 0,
},
value: {
x: 0.5,
y: 0.5,
},
onChange: () => {},
};

export default compose( [
withInstanceId,
withFocusOutside,
] )( FocalPointPicker );

39 changes: 39 additions & 0 deletions packages/components/src/focal-point-picker/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
$focalPointPickerSVG: "data:image/svg+xml;charset=utf8,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='translate(1 1)' stroke='%23000' strokeWidth='2' fill='none' fillRule='evenodd'%3E%3Cellipse fill='%23fff' cx='11' cy='11.129' rx='9.533' ry='9.645' /%3E%3Cpath d='M0 11.13h22M10.945 22.347V.09' strokeLinecap='square' /%3E%3C/g%3E%3C/svg%3E";

.component-focal-point-picker {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
border: 1px solid $light-gray-500;
cursor: pointer;
display: block;
height: 200px;
position: relative;
width: 100%;
}

.component-focal-point-picker__icon_container {
background-color: transparent;
cursor: -webkit-grab;
height: 30px;
opacity: 0.5;
position: absolute;
width: 30px;
z-index: 10000;

&.is-dragging {
cursor: -webkit-grabbing;
}
}

.component-focal-point-picker__icon {
background-image: url($focalPointPickerSVG);
background-size: 100%;
display: block;
height: 100%;
left: -15px;
position: absolute;
top: -15px;
width: 100%;
}
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down