diff --git a/.svnignore b/.svnignore index f9c2d78d307e7..8dfbb716215fa 100644 --- a/.svnignore +++ b/.svnignore @@ -41,3 +41,16 @@ yarn.lock docker bin/pre-commit-hook.js yarn-error.log +extensions/**/*.css +extensions/**/*.gif +extensions/**/*.jpeg +extensions/**/*.jpg +extensions/**/*.js +extensions/**/*.json +extensions/**/*.jsx +extensions/**/*.md +extensions/**/*.png +extensions/**/*.sass +extensions/**/*.scss +extensions/**/*.svg +**/__snapshots__ diff --git a/bin/build-asset-cdn-json.php b/bin/build-asset-cdn-json.php index 09c64e0ab4b6e..aebe99f45fdfc 100644 --- a/bin/build-asset-cdn-json.php +++ b/bin/build-asset-cdn-json.php @@ -1,20 +1,48 @@ $value ) { - $file = str_replace( $path, '', $file ); - $directory = substr( $file, 0, strpos( $file, '/' ) ); - if ( in_array( $directory, array( 'node_modules', 'tests' ) ) ) { +foreach ( $regex as $path_to_file => $value ) { + $path_from_repo_root = str_replace( $path, '', $path_to_file ); + + // Ignore top-level files. + if ( false === strpos( $path_from_repo_root, '/' ) ) { continue; } - $manifest[] = $file; + + // Ignore explicit ignore list. + foreach ( $ignore_paths as $ignore_path ) { + if ( 0 === strpos( $path_from_repo_root, $ignore_path ) ) { + continue 2; + } + } + + $manifest[] = $path_from_repo_root; } $export = var_export( $manifest, true ); -file_put_contents( $path . 'modules/photon-cdn/jetpack-manifest.php', " { + const { day } = this.props; + const { opening, closing } = interval; + return ( + +
+
+ { intervalIndex === 0 && this.renderDayToggle() } +
+
+ { + this.setHour( value, 'opening', intervalIndex ); + } } + /> + { + this.setHour( value, 'closing', intervalIndex ); + } } + /> +
+
+ { day.hours.length > 1 && ( + { + this.removeInterval( intervalIndex ); + } } + /> + ) } +
+
+ { intervalIndex === day.hours.length - 1 && ( +
+
 
+
+ + { __( 'Add Hours' ) } + +
+
 
+
+ ) } +
+ ); + }; + + setHour = ( hourValue, hourType, hourIndex ) => { + const { day, attributes, setAttributes } = this.props; + const { days } = attributes; + setAttributes( { + days: days.map( value => { + if ( value.name === day.name ) { + return { + ...value, + hours: value.hours.map( ( hour, index ) => { + if ( index === hourIndex ) { + return { + ...hour, + [ hourType ]: hourValue, + }; + } + return hour; + } ), + }; + } + return value; + } ), + } ); + }; + + toggleClosed = nextValue => { + const { day, attributes, setAttributes } = this.props; + const { days } = attributes; + + setAttributes( { + days: days.map( value => { + if ( value.name === day.name ) { + const hours = nextValue + ? [ + { + opening: defaultOpen, + closing: defaultClose, + }, + ] + : []; + return { + ...value, + hours, + }; + } + return value; + } ), + } ); + }; + + addInterval = () => { + const { day, attributes, setAttributes } = this.props; + const { days } = attributes; + day.hours.push( { opening: '', closing: '' } ); + setAttributes( { + days: days.map( value => { + if ( value.name === day.name ) { + return { + ...value, + hours: day.hours, + }; + } + return value; + } ), + } ); + }; + + removeInterval = hourIndex => { + const { day, attributes, setAttributes } = this.props; + const { days } = attributes; + + setAttributes( { + days: days.map( value => { + if ( day.name === value.name ) { + return { + ...value, + hours: value.hours.filter( ( hour, index ) => { + return hourIndex !== index; + } ), + }; + } + return value; + } ), + } ); + }; + + isClosed() { + const { day } = this.props; + return isEmpty( day.hours ); + } + + renderDayToggle() { + const { day, localization } = this.props; + return ( + + { localization.days[ day.name ] } + + + ); + } + + renderClosed() { + const { day } = this.props; + return ( +
+
+ { this.renderDayToggle() } +
+
 
+
 
+
+ ); + } + + render() { + const { day } = this.props; + return this.isClosed() ? this.renderClosed() : day.hours.map( this.renderInterval ); + } +} + +export default DayEdit; diff --git a/extensions/blocks/business-hours/components/day-preview.js b/extensions/blocks/business-hours/components/day-preview.js new file mode 100644 index 0000000000000..e9413d8ed90e1 --- /dev/null +++ b/extensions/blocks/business-hours/components/day-preview.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { Component, Fragment } from '@wordpress/element'; +import { date } from '@wordpress/date'; +import { isEmpty } from 'lodash'; +import { sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { _x } from '../../../utils/i18n'; + +class DayPreview extends Component { + formatTime( time ) { + const { timeFormat } = this.props; + const [ hours, minutes ] = time.split( ':' ); + const _date = new Date(); + if ( ! hours || ! minutes ) { + return false; + } + _date.setHours( hours ); + _date.setMinutes( minutes ); + return date( timeFormat, _date ); + } + + renderInterval = ( interval, key ) => { + return ( +
+ { sprintf( + _x( 'From %s to %s', 'from business opening hour to closing hour' ), + this.formatTime( interval.opening ), + this.formatTime( interval.closing ) + ) } +
+ ); + }; + + render() { + const { day, localization } = this.props; + const hours = day.hours.filter( + // remove any malformed or empty intervals + interval => this.formatTime( interval.opening ) && this.formatTime( interval.closing ) + ); + return ( + +
{ localization.days[ day.name ] }
+ { isEmpty( hours ) ? ( +
{ _x( 'Closed', 'business is closed on a full day' ) }
+ ) : ( + hours.map( this.renderInterval ) + ) } +
+ ); + } +} + +export default DayPreview; diff --git a/extensions/blocks/business-hours/edit.js b/extensions/blocks/business-hours/edit.js new file mode 100644 index 0000000000000..821631e8f4c0e --- /dev/null +++ b/extensions/blocks/business-hours/edit.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { BlockIcon } from '@wordpress/editor'; +import { Component } from '@wordpress/element'; +import { Placeholder } from '@wordpress/components'; +import apiFetch from '@wordpress/api-fetch'; +import classNames from 'classnames'; +import { __experimentalGetSettings } from '@wordpress/date'; + +/** + * Internal dependencies + */ +import DayEdit from './components/day-edit'; +import DayPreview from './components/day-preview'; +import { icon } from '.'; +import { __ } from '../../utils/i18n'; + +const defaultLocalization = { + days: { + Sun: __( 'Sunday' ), + Mon: __( 'Monday' ), + Tue: __( 'Tuesday' ), + Wed: __( 'Wednesday' ), + Thu: __( 'Thursday' ), + Fri: __( 'Friday' ), + Sat: __( 'Saturday' ), + }, + startOfWeek: 0, +}; + +class BusinessHours extends Component { + state = { + localization: defaultLocalization, + hasFetched: false, + }; + + componentDidMount() { + this.apiFetch(); + } + + apiFetch() { + this.setState( { data: defaultLocalization }, () => { + apiFetch( { path: '/wpcom/v2/business-hours/localized-week' } ).then( + data => { + this.setState( { localization: data, hasFetched: true } ); + }, + () => { + this.setState( { localization: defaultLocalization, hasFetched: true } ); + } + ); + } ); + } + + render() { + const { attributes, className, isSelected } = this.props; + const { days } = attributes; + const { localization, hasFetched } = this.state; + const { startOfWeek } = localization; + const localizedWeek = days.concat( days.slice( 0, startOfWeek ) ).slice( startOfWeek ); + + if ( ! hasFetched ) { + return ( + } + label={ __( 'Loading business hours' ) } + /> + ); + } + + if ( ! isSelected ) { + const settings = __experimentalGetSettings(); + const { + formats: { time }, + } = settings; + return ( +
+ { localizedWeek.map( ( day, key ) => { + return ( + + ); + } ) } +
+ ); + } + + return ( +
+ { localizedWeek.map( ( day, key ) => { + return ( + + ); + } ) } +
+ ); + } +} + +export default BusinessHours; diff --git a/extensions/blocks/business-hours/editor.js b/extensions/blocks/business-hours/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/business-hours/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/business-hours/editor.scss b/extensions/blocks/business-hours/editor.scss new file mode 100644 index 0000000000000..cb919a6d5153d --- /dev/null +++ b/extensions/blocks/business-hours/editor.scss @@ -0,0 +1,132 @@ +// @TODO: Use Gutenberg variables +$break-xlarge: 1080px; +$break-large: 960px; // admin sidebar auto folds +$break-medium: 782px; // editor sidebar auto folds +$break-small: 600px; + +.wp-block-jetpack-business-hours { + overflow: hidden; + + .business-hours__row { + display: flex; + + &.business-hours-row__add, + &.business-hours-row__closed { + margin-bottom: 20px; + } + + .business-hours__day { + width: 44%; + display: flex; + align-items: baseline; + + .business-hours__day-name { + width: 60%; + font-weight: bold; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .components-form-toggle { + margin-right: 4px; + } + } + + .business-hours__hours { + width: 44%; + margin: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + + .components-base-control { + display: inline-block; + margin-bottom: 0; + width: 48%; + + &.business-hours__open { + margin-right: 4%; + } + + .components-base-control__label { + margin-bottom: 0; + } + } + } + } + + .business-hours__remove { + align-self: flex-end; + margin-bottom: 8px; + text-align: center; + width: 10%; + } + + .business-hours-row__add button:hover { + box-shadow: none !important; + } + + .business-hours__remove button { + display: block; + margin: 0 auto; + } + + .business-hours-row__add .components-button.is-default:hover, + .business-hours__remove .components-button.is-default:hover, + .business-hours-row__add .components-button.is-default:focus, + .business-hours__remove .components-button.is-default:focus, + .business-hours-row__add .components-button.is-default:active, + .business-hours__remove .components-button.is-default:active { + background: none; + box-shadow: none; + } +} + +/** + * We consider the editor area to be small when the business hours block is: + * - within a column block + * - in a screen < xlarge size with the sidebar open + * - in a screen < small size + * In these cases we'll apply small screen styles. + */ +@mixin editor-area-is-small { + @media ( max-width: $break-xlarge ) { + .is-sidebar-opened { + @content; + } + } + @media ( max-width: $break-small ) { + @content; + } + + .wp-block-columns { + @content; + } +} + +@include editor-area-is-small() { + .wp-block-jetpack-business-hours { + .business-hours__row { + flex-wrap: wrap; + + &.business-hours-row__add { + .business-hours__day, + .business-hours__remove { + display: none; + } + } + + .business-hours__day { + width: 100%; + } + + .business-hours__hours { + width: 78%; + } + .business-hours__remove { + width: 18%; + } + } + } +} diff --git a/extensions/blocks/business-hours/index.js b/extensions/blocks/business-hours/index.js new file mode 100644 index 0000000000000..c1a2bbf062807 --- /dev/null +++ b/extensions/blocks/business-hours/index.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { Path } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '../../utils/i18n'; +import renderMaterialIcon from '../../utils/render-material-icon'; + +import './editor.scss'; +import BusinessHours from './edit'; + +/** + * Block Registrations: + */ + +export const name = 'business-hours'; + +export const icon = renderMaterialIcon( + +); + +export const settings = { + title: __( 'Business Hours' ), + description: __( 'Display opening hours for your business.' ), + icon, + category: 'jetpack', + supports: { + html: true, + }, + keywords: [ + _x( 'opening hours', 'block search term' ), + _x( 'closing time', 'block search term' ), + _x( 'schedule', 'block search term' ), + ], + attributes: { + days: { + type: 'array', + default: [ + { + name: 'Sun', + hours: [], // Closed by default + }, + { + name: 'Mon', + hours: [ + { + opening: '09:00', + closing: '17:00', + }, + ], + }, + { + name: 'Tue', + hours: [ + { + opening: '09:00', + closing: '17:00', + }, + ], + }, + { + name: 'Wed', + hours: [ + { + opening: '09:00', + closing: '17:00', + }, + ], + }, + { + name: 'Thu', + hours: [ + { + opening: '09:00', + closing: '17:00', + }, + ], + }, + { + name: 'Fri', + hours: [ + { + opening: '09:00', + closing: '17:00', + }, + ], + }, + { + name: 'Sat', + hours: [], // Closed by default + }, + ], + }, + }, + + edit: props => , + + save: () => null, +}; diff --git a/extensions/blocks/contact-form/components/jetpack-contact-form.js b/extensions/blocks/contact-form/components/jetpack-contact-form.js new file mode 100644 index 0000000000000..d17f970cf1432 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-contact-form.js @@ -0,0 +1,261 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { Button, PanelBody, Placeholder, TextControl, Path } from '@wordpress/components'; +import { InnerBlocks, InspectorControls } from '@wordpress/editor'; +import { Component, Fragment } from '@wordpress/element'; +import { sprintf } from '@wordpress/i18n'; +import emailValidator from 'email-validator'; +import { compose, withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; +import renderMaterialIcon from '../../../utils/render-material-icon'; +import SubmitButton from '../../../utils/submit-button'; +import HelpMessage from '../../../shared/help-message'; +const ALLOWED_BLOCKS = [ + 'jetpack/markdown', + 'core/paragraph', + 'core/image', + 'core/heading', + 'core/gallery', + 'core/list', + 'core/quote', + 'core/shortcode', + 'core/audio', + 'core/code', + 'core/cover', + 'core/file', + 'core/html', + 'core/separator', + 'core/spacer', + 'core/subhead', + 'core/table', + 'core/verse', + 'core/video', +]; + +class JetpackContactForm extends Component { + constructor( ...args ) { + super( ...args ); + this.onChangeSubject = this.onChangeSubject.bind( this ); + this.onBlurTo = this.onBlurTo.bind( this ); + this.onChangeTo = this.onChangeTo.bind( this ); + this.onChangeSubmit = this.onChangeSubmit.bind( this ); + this.onFormSettingsSet = this.onFormSettingsSet.bind( this ); + this.getToValidationError = this.getToValidationError.bind( this ); + this.renderToAndSubjectFields = this.renderToAndSubjectFields.bind( this ); + this.preventEnterSubmittion = this.preventEnterSubmittion.bind( this ); + this.hasEmailError = this.hasEmailError.bind( this ); + + const to = args[ 0 ].attributes.to ? args[ 0 ].attributes.to : ''; + const error = to + .split( ',' ) + .map( this.getToValidationError ) + .filter( Boolean ); + + this.state = { + toError: error && error.length ? error : null, + }; + } + + getIntroMessage() { + return __( + 'You’ll receive an email notification each time someone fills out the form. Where should it go, and what should the subject line be?' + ); + } + + getEmailHelpMessage() { + return __( 'You can enter multiple email addresses separated by commas.' ); + } + + onChangeSubject( subject ) { + this.props.setAttributes( { subject } ); + } + + getToValidationError( email ) { + email = email.trim(); + if ( email.length === 0 ) { + return false; // ignore the empty emails + } + if ( ! emailValidator.validate( email ) ) { + return { email }; + } + return false; + } + + onBlurTo( event ) { + const error = event.target.value + .split( ',' ) + .map( this.getToValidationError ) + .filter( Boolean ); + if ( error && error.length ) { + this.setState( { toError: error } ); + return; + } + } + + onChangeTo( to ) { + const emails = to.trim(); + if ( emails.length === 0 ) { + this.setState( { toError: null } ); + this.props.setAttributes( { to } ); + return; + } + + this.setState( { toError: null } ); + this.props.setAttributes( { to } ); + } + + onChangeSubmit( submitButtonText ) { + this.props.setAttributes( { submitButtonText } ); + } + + onFormSettingsSet( event ) { + event.preventDefault(); + if ( this.state.toError ) { + // don't submit the form if there are errors. + return; + } + this.props.setAttributes( { hasFormSettingsSet: 'yes' } ); + } + + getfieldEmailError( errors ) { + if ( errors ) { + if ( errors.length === 1 ) { + if ( errors[ 0 ] && errors[ 0 ].email ) { + return sprintf( __( '%s is not a valid email address.' ), errors[ 0 ].email ); + } + return errors[ 0 ]; + } + + if ( errors.length === 2 ) { + return sprintf( + __( '%s and %s are not a valid email address.' ), + errors[ 0 ].email, + errors[ 1 ].email + ); + } + const inValidEmails = errors.map( error => error.email ); + return sprintf( __( '%s are not a valid email address.' ), inValidEmails.join( ', ' ) ); + } + return null; + } + + preventEnterSubmittion( event ) { + if ( event.key === 'Enter' ) { + event.preventDefault(); + event.stopPropagation(); + } + } + + renderToAndSubjectFields() { + const fieldEmailError = this.state.toError; + const { instanceId, attributes } = this.props; + const { subject, to } = attributes; + return ( + + + + { this.getfieldEmailError( fieldEmailError ) } + + + { this.getEmailHelpMessage() } + + + + + ); + } + + hasEmailError() { + const fieldEmailError = this.state.toError; + return fieldEmailError && fieldEmailError.length > 0; + } + + render() { + const { className, attributes } = this.props; + const { hasFormSettingsSet } = attributes; + const formClassnames = classnames( className, 'jetpack-contact-form', { + 'has-intro': ! hasFormSettingsSet, + } ); + + return ( + + + + { this.renderToAndSubjectFields() } + + +
+ { ! hasFormSettingsSet && ( + + ) } + > +
+

{ this.getIntroMessage() }

+ { this.renderToAndSubjectFields() } +

+ { __( + '(If you leave these blank, notifications will go to the author with the post or page title as the subject line.)' + ) } +

+
+ +
+
+
+ ) } + { hasFormSettingsSet && ( + + ) } + { hasFormSettingsSet && } +
+
+ ); + } +} + +export default compose( [ withInstanceId ] )( JetpackContactForm ); diff --git a/extensions/blocks/contact-form/components/jetpack-field-checkbox.js b/extensions/blocks/contact-form/components/jetpack-field-checkbox.js new file mode 100644 index 0000000000000..d378c58936c97 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field-checkbox.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { BaseControl, PanelBody, TextControl, ToggleControl } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; +import { InspectorControls } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import JetpackFieldLabel from './jetpack-field-label'; +import { __ } from '../../../utils/i18n'; + +const JetpackFieldCheckbox = ( { + instanceId, + required, + label, + setAttributes, + isSelected, + defaultValue, + id, +} ) => { + return ( + + + + + + setAttributes( { defaultValue: value } ) } + /> + setAttributes( { id: value } ) } + /> + + + + } + /> + ); +}; + +export default withInstanceId( JetpackFieldCheckbox ); diff --git a/extensions/blocks/contact-form/components/jetpack-field-label.js b/extensions/blocks/contact-form/components/jetpack-field-label.js new file mode 100644 index 0000000000000..3493cab951f32 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field-label.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { PlainText } from '@wordpress/editor'; +import { ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; + +const JetpackFieldLabel = ( { setAttributes, label, resetFocus, isSelected, required } ) => { + return ( +
+ { + resetFocus && resetFocus(); + setAttributes( { label: value } ); + } } + placeholder={ __( 'Write label…' ) } + /> + { isSelected && ( + <ToggleControl + label={ __( 'Required' ) } + className="jetpack-field-label__required" + checked={ required } + onChange={ value => setAttributes( { required: value } ) } + /> + ) } + { ! isSelected && required && <span className="required">{ __( '(required)' ) }</span> } + </div> + ); +}; + +export default JetpackFieldLabel; diff --git a/extensions/blocks/contact-form/components/jetpack-field-multiple.js b/extensions/blocks/contact-form/components/jetpack-field-multiple.js new file mode 100644 index 0000000000000..791bc9c62c819 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field-multiple.js @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { BaseControl, IconButton, TextControl, PanelBody } from '@wordpress/components'; +import { withInstanceId } from '@wordpress/compose'; +import { Component, Fragment } from '@wordpress/element'; +import { InspectorControls } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import JetpackFieldLabel from './jetpack-field-label'; +import JetpackOption from './jetpack-option'; +import { __ } from '../../../utils/i18n'; + +class JetpackFieldMultiple extends Component { + constructor( ...args ) { + super( ...args ); + this.onChangeOption = this.onChangeOption.bind( this ); + this.addNewOption = this.addNewOption.bind( this ); + this.state = { inFocus: null }; + } + + onChangeOption( key = null, option = null ) { + const newOptions = this.props.options.slice( 0 ); + if ( null === option ) { + // Remove a key + newOptions.splice( key, 1 ); + if ( key > 0 ) { + this.setState( { inFocus: key - 1 } ); + } + } else { + // update a key + newOptions.splice( key, 1, option ); + this.setState( { inFocus: key } ); // set the focus. + } + this.props.setAttributes( { options: newOptions } ); + } + + addNewOption( key = null ) { + const newOptions = this.props.options.slice( 0 ); + let inFocus = 0; + if ( 'object' === typeof key ) { + newOptions.push( '' ); + inFocus = newOptions.length - 1; + } else { + newOptions.splice( key + 1, 0, '' ); + inFocus = key + 1; + } + + this.setState( { inFocus: inFocus } ); + this.props.setAttributes( { options: newOptions } ); + } + + render() { + const { type, instanceId, required, label, setAttributes, isSelected, id } = this.props; + let { options } = this.props; + let { inFocus } = this.state; + if ( ! options.length ) { + options = [ '' ]; + inFocus = 0; + } + + return ( + <Fragment> + <BaseControl + id={ `jetpack-field-multiple-${ instanceId }` } + className="jetpack-field jetpack-field-multiple" + label={ + <JetpackFieldLabel + required={ required } + label={ label } + setAttributes={ setAttributes } + isSelected={ isSelected } + resetFocus={ () => this.setState( { inFocus: null } ) } + /> + } + > + <ol + className="jetpack-field-multiple__list" + id={ `jetpack-field-multiple-${ instanceId }` } + > + { options.map( ( option, index ) => ( + <JetpackOption + type={ type } + key={ index } + option={ option } + index={ index } + onChangeOption={ this.onChangeOption } + onAddOption={ this.addNewOption } + isInFocus={ index === inFocus && isSelected } + isSelected={ isSelected } + /> + ) ) } + </ol> + { isSelected && ( + <IconButton + className="jetpack-field-multiple__add-option" + icon="insert" + label={ __( 'Insert option' ) } + onClick={ this.addNewOption } + > + { __( 'Add option' ) } + </IconButton> + ) } + </BaseControl> + + <InspectorControls> + <PanelBody title={ __( 'Field Settings' ) }> + <TextControl + label={ __( 'ID' ) } + value={ id } + onChange={ value => setAttributes( { id: value } ) } + /> + </PanelBody> + </InspectorControls> + </Fragment> + ); + } +} + +export default withInstanceId( JetpackFieldMultiple ); diff --git a/extensions/blocks/contact-form/components/jetpack-field-required-toggle.js b/extensions/blocks/contact-form/components/jetpack-field-required-toggle.js new file mode 100644 index 0000000000000..bab73001bd812 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field-required-toggle.js @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; + +const JetpackFieldRequiredToggle = ( { required, onChange } ) => { + return <ToggleControl label={ __( 'Required' ) } checked={ required } onChange={ onChange } />; +}; + +export default JetpackFieldRequiredToggle; diff --git a/extensions/blocks/contact-form/components/jetpack-field-textarea.js b/extensions/blocks/contact-form/components/jetpack-field-textarea.js new file mode 100644 index 0000000000000..186fc1e80953b --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field-textarea.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import { TextareaControl, TextControl, PanelBody } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import { InspectorControls } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import JetpackFieldLabel from './jetpack-field-label'; +import { __ } from '../../../utils/i18n'; + +function JetpackFieldTextarea( { + required, + label, + setAttributes, + isSelected, + defaultValue, + placeholder, + id, +} ) { + return ( + <Fragment> + <div className="jetpack-field"> + <TextareaControl + label={ + <JetpackFieldLabel + required={ required } + label={ label } + setAttributes={ setAttributes } + isSelected={ isSelected } + /> + } + placeholder={ placeholder } + value={ placeholder } + onChange={ value => setAttributes( { placeholder: value } ) } + title={ __( 'Set the placeholder text' ) } + /> + </div> + <InspectorControls> + <PanelBody title={ __( 'Field Settings' ) }> + <TextControl + label={ __( 'Default Value' ) } + value={ defaultValue } + onChange={ value => setAttributes( { defaultValue: value } ) } + /> + <TextControl + label={ __( 'ID' ) } + value={ id } + onChange={ value => setAttributes( { id: value } ) } + /> + </PanelBody> + </InspectorControls> + </Fragment> + ); +} + +export default JetpackFieldTextarea; diff --git a/extensions/blocks/contact-form/components/jetpack-field.js b/extensions/blocks/contact-form/components/jetpack-field.js new file mode 100644 index 0000000000000..77c3ffaa87213 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-field.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { TextControl, PanelBody } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import classNames from 'classnames'; +import { InspectorControls } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import JetpackFieldLabel from './jetpack-field-label'; +import { __ } from '../../../utils/i18n'; + +function JetpackField( { + isSelected, + type, + required, + label, + setAttributes, + defaultValue, + placeholder, + id, +} ) { + return ( + <Fragment> + <div className={ classNames( 'jetpack-field', { 'is-selected': isSelected } ) }> + <TextControl + type={ type } + label={ + <JetpackFieldLabel + required={ required } + label={ label } + setAttributes={ setAttributes } + isSelected={ isSelected } + /> + } + placeholder={ placeholder } + value={ placeholder } + onChange={ value => setAttributes( { placeholder: value } ) } + title={ __( 'Set the placeholder text' ) } + /> + </div> + <InspectorControls> + <PanelBody title={ __( 'Field Settings' ) }> + <TextControl + label={ __( 'Default Value' ) } + value={ defaultValue } + onChange={ value => setAttributes( { defaultValue: value } ) } + /> + <TextControl + label={ __( 'ID' ) } + value={ id } + onChange={ value => setAttributes( { id: value } ) } + /> + </PanelBody> + </InspectorControls> + </Fragment> + ); +} + +export default JetpackField; diff --git a/extensions/blocks/contact-form/components/jetpack-option.js b/extensions/blocks/contact-form/components/jetpack-option.js new file mode 100644 index 0000000000000..5d3052b8ab356 --- /dev/null +++ b/extensions/blocks/contact-form/components/jetpack-option.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { IconButton } from '@wordpress/components'; +import { Component, createRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; + +class JetpackOption extends Component { + constructor( ...args ) { + super( ...args ); + this.onChangeOption = this.onChangeOption.bind( this ); + this.onKeyPress = this.onKeyPress.bind( this ); + this.onDeleteOption = this.onDeleteOption.bind( this ); + this.textInput = createRef(); + } + + componentDidMount() { + if ( this.props.isInFocus ) { + this.textInput.current.focus(); + } + } + + componentDidUpdate() { + if ( this.props.isInFocus ) { + this.textInput.current.focus(); + } + } + + onChangeOption( event ) { + this.props.onChangeOption( this.props.index, event.target.value ); + } + + onKeyPress( event ) { + if ( event.key === 'Enter' ) { + this.props.onAddOption( this.props.index ); + event.preventDefault(); + return; + } + + if ( event.key === 'Backspace' && event.target.value === '' ) { + this.props.onChangeOption( this.props.index ); + event.preventDefault(); + return; + } + } + + onDeleteOption() { + this.props.onChangeOption( this.props.index ); + } + + render() { + const { isSelected, option, type } = this.props; + return ( + <li className="jetpack-option"> + { type && type !== 'select' && ( + <input className="jetpack-option__type" type={ type } disabled /> + ) } + <input + type="text" + className="jetpack-option__input" + value={ option } + placeholder={ __( 'Write option…' ) } + onChange={ this.onChangeOption } + onKeyDown={ this.onKeyPress } + ref={ this.textInput } + /> + { isSelected && ( + <IconButton + className="jetpack-option__remove" + icon="trash" + label={ __( 'Remove option' ) } + onClick={ this.onDeleteOption } + /> + ) } + </li> + ); + } +} + +export default JetpackOption; diff --git a/extensions/blocks/contact-form/editor.js b/extensions/blocks/contact-form/editor.js new file mode 100644 index 0000000000000..5b6bd3cdd88a0 --- /dev/null +++ b/extensions/blocks/contact-form/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { childBlocks, name, settings } from '.'; + +registerJetpackBlock( name, settings, childBlocks ); diff --git a/extensions/blocks/contact-form/editor.scss b/extensions/blocks/contact-form/editor.scss new file mode 100644 index 0000000000000..b988f7f40ef6f --- /dev/null +++ b/extensions/blocks/contact-form/editor.scss @@ -0,0 +1,694 @@ + +.jetpack-contact-form { + padding: 10px 18px; + + &.has-intro { + padding: 0; + } +} + +.jetpack-contact-form .components-placeholder { + padding: 24px; + + input[type='text'] { + width: 100%; + outline-width: 0; + outline-style: none; + line-height: 16px; + } + + .components-placeholder__label svg { + margin-right: 1ch; + } + + .help-message, + .components-placeholder__fieldset { + text-align: left; + } + + .help-message { + width: 100%; + margin: -18px 0 28px; + } + + .components-base-control { + margin-bottom: 16px; + width: 100%; + } +} + +.jetpack-contact-form__intro-message { + margin: 0 0 16px; +} + +.jetpack-contact-form__create { + width: 100%; +} + +.jetpack-field-label { + display: flex; + flex-direction: row; + + .components-base-control { + margin-top:-1px; + margin-bottom: -3px; + + .components-form-toggle { + margin: 2px 8px 0 0; + } + } + + .required { + color: #dc3232; + } + + .components-toggle-control .components-base-control__field { + margin-bottom: 0; + } +} + +.jetpack-field-label__input { + flex-grow: 1; + min-height: unset; + padding: 0; +} + +// Duplicated to elevate specificity in order to overwrite core styles +.jetpack-field-label__input.jetpack-field-label__input.jetpack-field-label__input { + border-color: #fff; + border-radius: 0; + font-weight: 600; + margin: 0; + margin-bottom: 2px; + padding: 0; + + &:focus { + border-color: #fff; + box-shadow: none; + } +} + +input.components-text-control__input { + line-height: 16px; +} + +.jetpack-field { + // done to increase elevate specificity in order to overwrite calypso styles + .components-text-control__input.components-text-control__input { + width: 100%; + } + .components-text-control__input, + .components-textarea-control__input { + color: #72777c; + padding: 10px 8px; + } +} + +.jetpack-field-checkbox__checkbox.jetpack-field-checkbox__checkbox.jetpack-field-checkbox__checkbox { + float: left; +} + +// Duplicated to elevate specificity in order to overwrite core styles +.jetpack-field-multiple__list.jetpack-field-multiple__list { + list-style-type: none; + margin: 0; + + &:empty { + display: none; + } + + // TODO: make this a class, @enej + [data-type='jetpack/field-select'] & { + border: 1px solid #8d96a0; + border-radius: 4px; + padding: 4px; + } +} + +.jetpack-option { + display: flex; + align-items: center; + margin: 0; +} + +.jetpack-option__type.jetpack-option__type { + margin-top: 0; +} + +// Duplicated to elevate specificity in order to overwrite core styles +.jetpack-option__input.jetpack-option__input.jetpack-option__input { + border-color: #fff; + border-radius: 0; + flex-grow: 1; + + &:hover { + border-color: #357cb5; + } + + &:focus { + border-color: #e3e5e8; + box-shadow: none; + } +} +// Duplicated to elevate specificity in order to overwrite calypso styles +.jetpack-option__remove.jetpack-option__remove { + padding: 6px; + vertical-align: bottom; +} + +.jetpack-field-multiple__add-option { + margin-left: -6px; + padding: 4px; + padding-right: 8px; + + svg { + margin-right: 12px; + } +} + +.jetpack-field-checkbox .components-base-control__label { + display: flex; + align-items: center; + + .jetpack-field-label { + flex-grow:1; + } + + .jetpack-field-label__input { + font-size: 13px; + font-weight: 400; + padding-left: 10px; + } +} + +/* ========================================================================== +** Shortcode Classic Block Styles +** ======================================================================= */ + +@media ( min-width: 481px ) { + .jetpack-contact-form-shortcode-preview { + padding: 24px; + } +} + +.jetpack-contact-form-shortcode-preview { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; + font-size: 16px; + line-height: 1.4em; + display: block; + position: relative; + margin: 0 auto; + padding: 16px; + box-sizing: border-box; + background: white; + box-shadow: 0 0 0 1px rgba( 200, 215, 225, 0.5 ), 0 1px 2px #e9eff3; + + &::after { + content: '.'; + display: block; + height: 0; + clear: both; + visibility: hidden; + } + + > div { + margin-top: 24px; + } + + > div:first-child { + margin-top: 0; + } + /* ========================================================================== + ** Labels + ** ======================================================================= */ + + label { + display: block; + font-size: 14px; + font-weight: 600; + margin-bottom: 5px; + } + + + /* ========================================================================== + ** Text Inputs + ** ======================================================================= */ + + input[type='text'], + input[type='tel'], + input[type='email'], + input[type='url'] { + border-radius: 0; + appearance: none; + box-sizing: border-box; + margin: 0; + padding: 7px 14px; + width: 100%; + color: #2e4453; + font-size: 16px; + line-height: 1.5; + border: 1px solid #c8d7e1; + background-color: #fff; + transition: all 0.15s ease-in-out; + box-shadow: none; + } + + input[type='text']::placeholder, + input[type='tel']::placeholder, + input[type='email']::placeholder, + input[type='url']::placeholder { + color: #87a6bc; + } + + input[type='text']:hover, + input[type='tel']:hover, + input[type='email']:hover, + input[type='url']:hover { + border-color: #a8bece; + } + + input[type='text']:focus, + input[type='tel']:focus, + input[type='email']:focus, + input[type='url']:focus { + border-color: #0087be; + outline: none; + box-shadow: 0 0 0 2px #78dcfa; + } + + input[type='text']:focus::-ms-clear, + input[type='tel']:focus::-ms-clear, + input[type='email']:focus::-ms-clear, + input[type='url']:focus::-ms-clear { + display: none; + } + + input[type='text']:disabled, + input[type='tel']:disabled, + input[type='email']:disabled, + input[type='url']:disabled { + background: #f3f6f8; + border-color: #e9eff3; + color: #a8bece; + -webkit-text-fill-color: #a8bece; + } + + input[type='text']:disabled:hover, + input[type='tel']:disabled:hover, + input[type='email']:disabled:hover, + input[type='url']:disabled:hover { + cursor: default; + } + + input[type='text']:disabled::placeholder, + input[type='tel']:disabled::placeholder, + input[type='email']:disabled::placeholder, + input[type='url']:disabled::placeholder { + color: #a8bece; + } + + + /* ========================================================================== + ** Textareas + ** ======================================================================= */ + + textarea { + border-radius: 0; + appearance: none; + box-sizing: border-box; + margin: 0; + padding: 7px 14px; + height: 92px; + width: 100%; + color: #2e4453; + font-size: 16px; + line-height: 1.5; + border: 1px solid #c8d7e1; + background-color: #fff; + transition: all 0.15s ease-in-out; + box-shadow: none; + } + + textarea::placeholder { + color: #87a6bc; + } + + textarea:hover { + border-color: #a8bece; + } + + textarea:focus { + border-color: #0087be; + outline: none; + box-shadow: 0 0 0 2px #78dcfa; + } + + textarea:focus::-ms-clear { + display: none; + } + + textarea:disabled { + background: #f3f6f8; + border-color: #e9eff3; + color: #a8bece; + -webkit-text-fill-color: #a8bece; + } + + textarea:disabled:hover { + cursor: default; + } + + textarea:disabled::placeholder { + color: #a8bece; + } + + + /* ========================================================================== + ** Checkboxes + ** ======================================================================= */ + + input[type='checkbox'] { + -webkit-appearance: none; + display: inline-block; + box-sizing: border-box; + margin: 2px 0 0; + padding: 7px 14px; + width: 16px; + height: 16px; + float: left; + outline: 0; + padding: 0; + box-shadow: none; + background-color: #fff; + border: 1px solid #c8d7e1; + color: #2e4453; + font-size: 16px; + line-height: 0; + text-align: center; + vertical-align: middle; + appearance: none; + transition: all 0.15s ease-in-out; + clear: none; + cursor: pointer; + } + + input[type='checkbox']:checked::before { + content: '\f147'; + font-family: Dashicons; + margin: -3px 0 0 -4px; + float: left; + display: inline-block; + vertical-align: middle; + width: 16px; + font-size: 20px; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + speak: none; + color: #00aadc; + } + + input[type='checkbox']:disabled:checked::before { + color: #a8bece; + } + + input[type='checkbox']:hover { + border-color: #a8bece; + } + + input[type='checkbox']:focus { + border-color: #0087be; + outline: none; + box-shadow: 0 0 0 2px #78dcfa; + } + + input[type='checkbox']:disabled { + background: #f3f6f8; + border-color: #e9eff3; + color: #a8bece; + opacity: 1; + } + + input[type='checkbox']:disabled:hover { + cursor: default; + } + + input[type='checkbox'] + span { + display: block; + font-weight: normal; + margin-left: 24px; + } + + + /* ========================================================================== + ** Radio buttons + ** ======================================================================== */ + + input[type=radio] { + color: #2e4453; + font-size: 16px; + border: 1px solid #c8d7e1; + background-color: #fff; + transition: all 0.15s ease-in-out; + box-sizing: border-box; + -webkit-appearance: none; + clear: none; + cursor: pointer; + display: inline-block; + line-height: 0; + height: 16px; + margin: 2px 4px 0 0; + float: left; + outline: 0; + padding: 0; + text-align: center; + vertical-align: middle; + width: 16px; + min-width: 16px; + appearance: none; + border-radius: 50%; + line-height: 10px; + } + + input[type='radio']:hover { + border-color: #a8bece; + } + + input[type='radio']:focus { + border-color: #0087be; + outline: none; + box-shadow: 0 0 0 2px #78dcfa; + } + + input[type='radio']:focus::-ms-clear { + display: none; + } + + input[type='radio']:checked::before { + float: left; + display: inline-block; + content: '\2022'; + margin: 3px; + width: 8px; + height: 8px; + text-indent: -9999px; + background: #00aadc; + vertical-align: middle; + border-radius: 50%; + animation: grow 0.2s ease-in-out; + } + + input[type='radio']:disabled { + background: #f3f6f8; + border-color: #e9eff3; + color: #a8bece; + opacity: 1; + -webkit-text-fill-color: #a8bece; + } + + input[type='radio']:disabled:hover { + cursor: default; + } + + input[type='radio']:disabled::placeholder { + color: #a8bece; + } + + input[type='radio']:disabled:checked::before { + background: #e9eff3; + } + + input[type='radio'] + span { + display: block; + font-weight: normal; + margin-left: 24px; + } + + @keyframes grow { + 0% { + transform: scale( 0.3 ); + } + + 60% { + transform: scale( 1.15 ); + } + + 100% { + transform: scale( 1 ); + } + } + + @keyframes grow { + 0% { + transform: scale( 0.3 ); + } + + 60% { + transform: scale( 1.15 ); + } + + 100% { + transform: scale( 1 ); + } + } + + + /* ========================================================================== + ** Selects + ** ======================================================================== */ + + select { + background: #fff url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjQzhEN0UxIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== ) no-repeat right 10px center; + border-color: #c8d7e1; + border-style: solid; + border-radius: 4px; + border-width: 1px 1px 2px; + color: #2e4453; + cursor: pointer; + display: inline-block; + margin: 0; + outline: 0; + overflow: hidden; + font-size: 14px; + line-height: 21px; + font-weight: 600; + text-overflow: ellipsis; + text-decoration: none; + vertical-align: top; + white-space: nowrap; + box-sizing: border-box; + padding: 2px 32px 2px 14px; // Aligns the text to the 8px baseline grid and adds padding on right to allow for the arrow. + appearance: none; + font-family: sans-serif; + } + + select:hover { + background-image: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjYThiZWNlIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== ); + } + + select:focus { + background-image: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiA8dGl0bGU+YXJyb3ctZG93bjwvdGl0bGU+IDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiA8ZGVmcz48L2RlZnM+IDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPiA8ZyBpZD0iYXJyb3ctZG93biIgc2tldGNoOnR5cGU9Ik1TQXJ0Ym9hcmRHcm91cCIgZmlsbD0iIzJlNDQ1MyI+IDxwYXRoIGQ9Ik0xNS41LDYgTDE3LDcuNSBMMTAuMjUsMTQuMjUgTDMuNSw3LjUgTDUsNiBMMTAuMjUsMTEuMjUgTDE1LjUsNiBaIiBpZD0iRG93bi1BcnJvdyIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPiA8L2c+IDwvZz48L3N2Zz4= ); + border-color: #00aadc; + box-shadow: 0 0 0 2px #78dcfa; + outline: 0; + -moz-outline:none; + -moz-user-focus:ignore; + } + + select:disabled, + select:hover:disabled { + background: url( data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPiAgICAgICAgPHRpdGxlPmFycm93LWRvd248L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4gICAgICAgIDxnIGlkPSJhcnJvdy1kb3duIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIiBmaWxsPSIjZTllZmYzIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNSw2IEwxNyw3LjUgTDEwLjI1LDE0LjI1IEwzLjUsNy41IEw1LDYgTDEwLjI1LDExLjI1IEwxNS41LDYgWiIgaWQ9IkRvd24tQXJyb3ciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg== ) no-repeat right 10px center;; + } + + select.is-compact { + min-width: 0; + padding: 0 20px 2px 6px; + margin: 0 4px; + background-position: right 5px center; + background-size: 12px 12px; + } + + /* Make it display:block when it follows a label */ + label select, + label + select { + display: block; + min-width: 200px; + } + + label select.is-compact, + label + select.is-compact { + display: inline-block; + min-width: 0; + } + + /* IE: Remove the default arrow */ + select::-ms-expand { + display: none; + } + + /* IE: Remove default background and color styles on focus */ + select::-ms-value { + background: none; + color: #2e4453; + } + + /* Firefox: Remove the focus outline, see http://stackoverflow.com/questions/3773430/remove-outline-from-select-box-in-ff/18853002#18853002 */ + select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #2e4453; + } + + + /* ========================================================================== + ** Buttons + ** ======================================================================== */ + + input[type='submit'] { + padding: 0; + font-size: 14px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + vertical-align: baseline; + background: white; + border-color: #c8d7e1; + border-style: solid; + border-width: 1px 1px 2px; + color: #2e4453; + cursor: pointer; + display: inline-block; + margin: 24px 0 0; + outline: 0; + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + text-decoration: none; + vertical-align: top; + box-sizing: border-box; + font-size: 14px; + line-height: 21px; + border-radius: 4px; + padding: 7px 14px 9px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + } + + input[type='submit']:hover { + border-color: #a8bece; + color: #2e4453; + } + + input[type='submit']:active { + border-width: 2px 1px 1px; + } + + input[type='submit']:visited { + color: #2e4453; + } + + input[type='submit']:focus { + border-color: #00aadc; + box-shadow: 0 0 0 2px #78dcfa; + } +} diff --git a/extensions/blocks/contact-form/index.js b/extensions/blocks/contact-form/index.js new file mode 100644 index 0000000000000..c3e75cbd21495 --- /dev/null +++ b/extensions/blocks/contact-form/index.js @@ -0,0 +1,440 @@ +/** + * External dependencies + */ +import { getBlockType, createBlock } from '@wordpress/blocks'; +import { Path, Circle } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import { InnerBlocks } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import './editor.scss'; +import JetpackContactForm from './components/jetpack-contact-form'; +import JetpackField from './components/jetpack-field'; +import JetpackFieldTextarea from './components/jetpack-field-textarea'; +import JetpackFieldCheckbox from './components/jetpack-field-checkbox'; +import JetpackFieldMultiple from './components/jetpack-field-multiple'; +import { __ } from '../../utils/i18n'; +import renderMaterialIcon from '../../utils/render-material-icon'; + +export const name = 'contact-form'; + +export const settings = { + title: __( 'Form' ), + description: __( 'A simple way to get feedback from folks visiting your site.' ), + icon: renderMaterialIcon( + <Path d="M13 7.5h5v2h-5zm0 7h5v2h-5zM19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM11 6H6v5h5V6zm-1 4H7V7h3v3zm1 3H6v5h5v-5zm-1 4H7v-3h3v3z" /> + ), + keywords: [ __( 'email' ), __( 'feedback' ), __( 'contact' ) ], + category: 'jetpack', + supports: { + reusable: false, + html: false, + }, + attributes: { + subject: { + type: 'string', + default: '', + }, + to: { + type: 'string', + default: '', + }, + submitButtonText: { + type: 'string', + default: __( 'Submit' ), + }, + customBackgroundButtonColor: { type: 'string' }, + customTextButtonColor: { type: 'string' }, + submitButtonClasses: { type: 'string' }, + hasFormSettingsSet: { + type: 'string', + default: null, + }, + + // Deprecated + has_form_settings_set: { + type: 'string', + default: null, + }, + submit_button_text: { + type: 'string', + default: __( 'Submit' ), + }, + }, + + edit: JetpackContactForm, + save: InnerBlocks.Content, + deprecated: [ + { + attributes: { + subject: { + type: 'string', + default: '', + }, + to: { + type: 'string', + default: '', + }, + submit_button_text: { + type: 'string', + default: __( 'Submit' ), + }, + has_form_settings_set: { + type: 'string', + default: null, + }, + }, + migrate: attr => { + return { + submitButtonText: attr.submit_button_text, + hasFormSettingsSet: attr.has_form_settings_set, + to: attr.to, + subject: attr.subject, + }; + }, + + isEligible: attr => { + // when the deprecated, snake_case values are default, no need to migrate + if ( ! attr.has_form_settings_set && attr.submit_button_text === 'Submit' ) { + return false; + } + return true; + }, + + save: InnerBlocks.Content, + }, + ], +}; + +const FieldDefaults = { + category: 'jetpack', + parent: [ 'jetpack/contact-form' ], + supports: { + reusable: false, + html: false, + }, + attributes: { + label: { + type: 'string', + default: null, + }, + required: { + type: 'boolean', + default: false, + }, + options: { + type: 'array', + default: [], + }, + defaultValue: { + type: 'string', + default: '', + }, + placeholder: { + type: 'string', + default: '', + }, + id: { + type: 'string', + default: '', + }, + }, + transforms: { + to: [ + { + type: 'block', + blocks: [ 'jetpack/field-text' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-text', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-name' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-name', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-email' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-email', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-url' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-url', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-date' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-date', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-telephone' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-telephone', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-textarea' ], + isMatch: ( { options } ) => ! options.length, + transform: attributes => createBlock( 'jetpack/field-textarea', attributes ), + }, + /* // not yet ready for prime time. + { + type: 'block', + blocks: [ 'jetpack/field-checkbox' ], + isMatch: ( { options } ) => 1 === options.length, + transform: ( attributes )=>createBlock( 'jetpack/field-checkbox', attributes ) + }, + */ + { + type: 'block', + blocks: [ 'jetpack/field-checkbox-multiple' ], + isMatch: ( { options } ) => 1 <= options.length, + transform: attributes => createBlock( 'jetpack/field-checkbox-multiple', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-radio' ], + isMatch: ( { options } ) => 1 <= options.length, + transform: attributes => createBlock( 'jetpack/field-radio', attributes ), + }, + { + type: 'block', + blocks: [ 'jetpack/field-select' ], + isMatch: ( { options } ) => 1 <= options.length, + transform: attributes => createBlock( 'jetpack/field-select', attributes ), + }, + ], + }, + save: () => null, +}; + +const getFieldLabel = ( { attributes, name: blockName } ) => { + return null === attributes.label ? getBlockType( blockName ).title : attributes.label; +}; + +const editField = type => props => ( + <JetpackField + type={ type } + label={ getFieldLabel( props ) } + required={ props.attributes.required } + setAttributes={ props.setAttributes } + isSelected={ props.isSelected } + defaultValue={ props.attributes.defaultValue } + placeholder={ props.attributes.placeholder } + id={ props.attributes.id } + /> +); + +const editMultiField = type => props => ( + <JetpackFieldMultiple + label={ getFieldLabel( props ) } + required={ props.attributes.required } + options={ props.attributes.options } + setAttributes={ props.setAttributes } + type={ type } + isSelected={ props.isSelected } + id={ props.attributes.id } + /> +); + +export const childBlocks = [ + { + name: 'field-text', + settings: { + ...FieldDefaults, + title: __( 'Text' ), + description: __( 'When you need just a small amount of text, add a text input.' ), + icon: renderMaterialIcon( <Path d="M4 9h16v2H4V9zm0 4h10v2H4v-2z" /> ), + edit: editField( 'text' ), + }, + }, + { + name: 'field-name', + settings: { + ...FieldDefaults, + title: __( 'Name' ), + description: __( 'Introductions are important. Add an input for folks to add their name.' ), + icon: renderMaterialIcon( + <Path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> + ), + edit: editField( 'text' ), + }, + }, + { + name: 'field-email', + settings: { + ...FieldDefaults, + title: __( 'Email' ), + keywords: [ __( 'e-mail' ), __( 'mail' ), 'email' ], + description: __( 'Want to reply to folks? Add an email address input.' ), + icon: renderMaterialIcon( + <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" /> + ), + edit: editField( 'email' ), + }, + }, + + { + name: 'field-url', + settings: { + ...FieldDefaults, + title: __( 'Website' ), + keywords: [ 'url', __( 'internet page' ), 'link' ], + description: __( 'Add an address input for a website.' ), + icon: renderMaterialIcon( + <Path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" /> + ), + edit: editField( 'url' ), + }, + }, + + { + name: 'field-date', + settings: { + ...FieldDefaults, + title: __( 'Date Picker' ), + keywords: [ __( 'Calendar' ), __( 'day month year', 'block search term' ) ], + description: __( 'The best way to set a date. Add a date picker.' ), + icon: renderMaterialIcon( + <Path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V9h14v10zm0-12H5V5h14v2zM7 11h5v5H7z" /> + ), + edit: editField( 'text' ), + }, + }, + { + name: 'field-telephone', + settings: { + ...FieldDefaults, + title: __( 'Telephone' ), + keywords: [ __( 'Phone' ), __( 'Cellular phone' ), __( 'Mobile' ) ], + description: __( 'Add a phone number input.' ), + icon: renderMaterialIcon( + <Path d="M6.54 5c.06.89.21 1.76.45 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79h1.51m9.86 12.02c.85.24 1.72.39 2.6.45v1.49c-1.32-.09-2.59-.35-3.8-.75l1.2-1.19M7.5 3H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.49c0-.55-.45-1-1-1-1.24 0-2.45-.2-3.57-.57-.1-.04-.21-.05-.31-.05-.26 0-.51.1-.71.29l-2.2 2.2c-2.83-1.45-5.15-3.76-6.59-6.59l2.2-2.2c.28-.28.36-.67.25-1.02C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1z" /> + ), + edit: editField( 'tel' ), + }, + }, + { + name: 'field-textarea', + settings: { + ...FieldDefaults, + title: __( 'Message' ), + keywords: [ __( 'Textarea' ), 'textarea', __( 'Multiline text' ) ], + description: __( 'Let folks speak their mind. This text box is great for longer responses.' ), + icon: renderMaterialIcon( <Path d="M21 11.01L3 11v2h18zM3 16h12v2H3zM21 6H3v2.01L21 8z" /> ), + edit: props => ( + <JetpackFieldTextarea + label={ getFieldLabel( props ) } + required={ props.attributes.required } + setAttributes={ props.setAttributes } + isSelected={ props.isSelected } + defaultValue={ props.attributes.defaultValue } + placeholder={ props.attributes.placeholder } + id={ props.attributes.id } + /> + ), + }, + }, + { + name: 'field-checkbox', + settings: { + ...FieldDefaults, + title: __( 'Checkbox' ), + keywords: [ __( 'Confirm' ), __( 'Accept' ) ], + description: __( 'Add a single checkbox.' ), + icon: renderMaterialIcon( + <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z" /> + ), + edit: props => ( + <JetpackFieldCheckbox + label={ props.attributes.label } // label intentinally left blank + required={ props.attributes.required } + setAttributes={ props.setAttributes } + isSelected={ props.isSelected } + defaultValue={ props.attributes.defaultValue } + id={ props.attributes.id } + /> + ), + attributes: { + ...FieldDefaults.attributes, + label: { + type: 'string', + default: '', + }, + }, + }, + }, + { + name: 'field-checkbox-multiple', + settings: { + ...FieldDefaults, + title: __( 'Checkbox Group' ), + keywords: [ __( 'Choose Multiple' ), __( 'Option' ) ], + description: __( 'People love options. Add several checkbox items.' ), + icon: renderMaterialIcon( + <Path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z" /> + ), + edit: editMultiField( 'checkbox' ), + attributes: { + ...FieldDefaults.attributes, + label: { + type: 'string', + default: 'Choose several', + }, + }, + }, + }, + { + name: 'field-radio', + settings: { + ...FieldDefaults, + title: __( 'Radio' ), + keywords: [ __( 'Choose' ), __( 'Select' ), __( 'Option' ) ], + description: __( + 'Inspired by radios, only one radio item can be selected at a time. Add several radio button items.' + ), + icon: renderMaterialIcon( + <Fragment> + <Path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" /> + <Circle cx="12" cy="12" r="5" /> + </Fragment> + ), + edit: editMultiField( 'radio' ), + attributes: { + ...FieldDefaults.attributes, + label: { + type: 'string', + default: 'Choose one', + }, + }, + }, + }, + { + name: 'field-select', + settings: { + ...FieldDefaults, + title: __( 'Select' ), + keywords: [ __( 'Choose' ), __( 'Dropdown' ), __( 'Option' ) ], + description: __( 'Compact, but powerful. Add a select box with several items.' ), + icon: renderMaterialIcon( + <Path d="M3 17h18v2H3zm16-5v1H5v-1h14m2-2H3v5h18v-5zM3 6h18v2H3z" /> + ), + edit: editMultiField( 'select' ), + attributes: { + ...FieldDefaults.attributes, + label: { + type: 'string', + default: 'Select one', + }, + }, + }, + }, +]; diff --git a/extensions/blocks/contact-info/address/edit.js b/extensions/blocks/contact-info/address/edit.js new file mode 100644 index 0000000000000..5ba64592556fa --- /dev/null +++ b/extensions/blocks/contact-info/address/edit.js @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { PlainText } from '@wordpress/editor'; +import { Component, Fragment } from '@wordpress/element'; +import { ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; +import { default as save } from './save'; + +class AddressEdit extends Component { + constructor( ...args ) { + super( ...args ); + + this.preventEnterKey = this.preventEnterKey.bind( this ); + } + + preventEnterKey( event ) { + if ( event.key === 'Enter' ) { + event.preventDefault(); + return; + } + } + + render() { + const { + attributes: { + address, + addressLine2, + addressLine3, + city, + region, + postal, + country, + linkToGoogleMaps, + }, + isSelected, + setAttributes, + } = this.props; + + const hasContent = [ address, addressLine2, addressLine3, city, region, postal, country ].some( + value => value !== '' + ); + const classNames = classnames( { + 'jetpack-address-block': true, + 'is-selected': isSelected, + } ); + + const externalLink = ( + <ToggleControl + label={ __( 'Link address to Google Maps' ) } + checked={ linkToGoogleMaps } + onChange={ newlinkToGoogleMaps => + setAttributes( { linkToGoogleMaps: newlinkToGoogleMaps } ) + } + /> + ); + + return ( + <div className={ classNames }> + { ! isSelected && hasContent && save( this.props ) } + { ( isSelected || ! hasContent ) && ( + <Fragment> + <PlainText + value={ address } + placeholder={ __( 'Street Address' ) } + aria-label={ __( 'Street Address' ) } + onChange={ newAddress => setAttributes( { address: newAddress } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ addressLine2 } + placeholder={ __( 'Address Line 2' ) } + aria-label={ __( 'Address Line 2' ) } + onChange={ newAddressLine2 => setAttributes( { addressLine2: newAddressLine2 } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ addressLine3 } + placeholder={ __( 'Address Line 3' ) } + aria-label={ __( 'Address Line 3' ) } + onChange={ newAddressLine3 => setAttributes( { addressLine3: newAddressLine3 } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ city } + placeholder={ __( 'City' ) } + aria-label={ __( 'City' ) } + onChange={ newCity => setAttributes( { city: newCity } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ region } + placeholder={ __( 'State/Province/Region' ) } + aria-label={ __( 'State/Province/Region' ) } + onChange={ newRegion => setAttributes( { region: newRegion } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ postal } + placeholder={ __( 'Postal/Zip Code' ) } + aria-label={ __( 'Postal/Zip Code' ) } + onChange={ newPostal => setAttributes( { postal: newPostal } ) } + onKeyDown={ this.preventEnterKey } + /> + <PlainText + value={ country } + placeholder={ __( 'Country' ) } + aria-label={ __( 'Country' ) } + onChange={ newCountry => setAttributes( { country: newCountry } ) } + onKeyDown={ this.preventEnterKey } + /> + { externalLink } + </Fragment> + ) } + </div> + ); + } +} + +export default AddressEdit; diff --git a/extensions/blocks/contact-info/address/editor.js b/extensions/blocks/contact-info/address/editor.js new file mode 100644 index 0000000000000..4f9f21a0fde07 --- /dev/null +++ b/extensions/blocks/contact-info/address/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/contact-info/address/index.js b/extensions/blocks/contact-info/address/index.js new file mode 100644 index 0000000000000..7e0daca65cbe4 --- /dev/null +++ b/extensions/blocks/contact-info/address/index.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { Path, Circle } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import renderMaterialIcon from '../../../utils/render-material-icon'; +import { __, _x } from '../../../utils/i18n'; + +const attributes = { + address: { + type: 'string', + default: '', + }, + addressLine2: { + type: 'string', + default: '', + }, + addressLine3: { + type: 'string', + default: '', + }, + city: { + type: 'string', + default: '', + }, + region: { + type: 'string', + default: '', + }, + postal: { + type: 'string', + default: '', + }, + country: { + type: 'string', + default: '', + }, + linkToGoogleMaps: { + type: 'boolean', + default: false, + }, +}; + +export const name = 'address'; + +export const settings = { + title: __( 'Address' ), + description: __( 'Lets you add a physical address with Schema markup.' ), + keywords: [ + _x( 'location', 'block search term' ), + _x( 'direction', 'block search term' ), + _x( 'place', 'block search term' ), + ], + icon: renderMaterialIcon( + <Fragment> + <Path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zM7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 2.88-2.88 7.19-5 9.88C9.92 16.21 7 11.85 7 9z" /> + <Circle cx="12" cy="9" r="2.5" /> + </Fragment> + ), + category: 'jetpack', + attributes, + parent: [ 'jetpack/contact-info' ], + edit, + save, +}; diff --git a/extensions/blocks/contact-info/address/save.js b/extensions/blocks/contact-info/address/save.js new file mode 100644 index 0000000000000..9369b12d8e43b --- /dev/null +++ b/extensions/blocks/contact-info/address/save.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; + +const hasAddress = ( { address, addressLine2, addressLine3, city, region, postal, country } ) => { + return [ address, addressLine2, addressLine3, city, region, postal, country ].some( + value => value !== '' + ); +}; + +const Address = ( { + attributes: { address, addressLine2, addressLine3, city, region, postal, country }, +} ) => ( + <Fragment> + { address && ( + <div className="jetpack-address__address jetpack-address__address1">{ address }</div> + ) } + { addressLine2 && ( + <div className="jetpack-address__address jetpack-address__address2">{ addressLine2 }</div> + ) } + { addressLine3 && ( + <div className="jetpack-address__address jetpack-address__address3">{ addressLine3 }</div> + ) } + { city && ! ( region || postal ) && <div className="jetpack-address__city">{ city }</div> } + { city && ( region || postal ) && ( + <div> + { [ + <span className="jetpack-address__city">{ city }</span>, + ', ', + <span className="jetpack-address__region">{ region }</span>, + ' ', + <span className="jetpack-address__postal">{ postal }</span>, + ] } + </div> + ) } + { ! city && ( region || postal ) && ( + <div> + { [ + <span className="jetpack-address__region">{ region }</span>, + ' ', + <span className="jetpack-address__postal">{ postal }</span>, + ] } + </div> + ) } + { country && <div className="jetpack-address__country">{ country }</div> } + </Fragment> +); + +export const googleMapsUrl = ( { + attributes: { address, addressLine2, addressLine3, city, region, postal, country }, +} ) => { + const addressUrl = address ? `${ address },` : ''; + const addressLine2Url = addressLine2 ? `${ addressLine2 },` : ''; + const addressLine3Url = addressLine3 ? `${ addressLine3 },` : ''; + const cityUrl = city ? `+${ city },` : ''; + let regionUrl = region ? `+${ region },` : ''; + regionUrl = postal ? `${ regionUrl }+${ postal }` : regionUrl; + const countryUrl = country ? `+${ country }` : ''; + + return `https://www.google.com/maps/search/${ addressUrl }${ addressLine2Url }${ addressLine3Url }${ cityUrl }${ regionUrl }${ countryUrl }`.replace( + ' ', + '+' + ); +}; + +const save = props => + hasAddress( props.attributes ) && ( + <div className={ props.className }> + { props.attributes.linkToGoogleMaps && ( + <a + href={ googleMapsUrl( props ) } + target="_blank" + rel="noopener noreferrer" + title={ __( 'Open address in Google Maps' ) } + > + <Address { ...props } /> + </a> + ) } + { ! props.attributes.linkToGoogleMaps && <Address { ...props } /> } + </div> + ); + +export default save; diff --git a/extensions/blocks/contact-info/edit.js b/extensions/blocks/contact-info/edit.js new file mode 100644 index 0000000000000..b3ba63a66536b --- /dev/null +++ b/extensions/blocks/contact-info/edit.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { InnerBlocks } from '@wordpress/editor'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +const ALLOWED_BLOCKS = [ + 'jetpack/markdown', + 'jetpack/address', + 'jetpack/email', + 'jetpack/phone', + 'jetpack/map', + 'jetpack/business-hours', + 'core/paragraph', + 'core/image', + 'core/heading', + 'core/gallery', + 'core/list', + 'core/quote', + 'core/shortcode', + 'core/audio', + 'core/code', + 'core/cover', + 'core/html', + 'core/separator', + 'core/spacer', + 'core/subhead', + 'core/video', +]; + +const TEMPLATE = [ [ 'jetpack/email' ], [ 'jetpack/phone' ], [ 'jetpack/address' ] ]; + +const ContactInfoEdit = props => { + const { isSelected } = props; + + return ( + <div + className={ classnames( { + 'jetpack-contact-info-block': true, + 'is-selected': isSelected, + } ) } + > + <InnerBlocks allowedBlocks={ ALLOWED_BLOCKS } templateLock={ false } template={ TEMPLATE } /> + </div> + ); +}; + +export default ContactInfoEdit; diff --git a/extensions/blocks/contact-info/editor.js b/extensions/blocks/contact-info/editor.js new file mode 100644 index 0000000000000..5b6bd3cdd88a0 --- /dev/null +++ b/extensions/blocks/contact-info/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { childBlocks, name, settings } from '.'; + +registerJetpackBlock( name, settings, childBlocks ); diff --git a/extensions/blocks/contact-info/editor.scss b/extensions/blocks/contact-info/editor.scss new file mode 100644 index 0000000000000..2e1e08a1cec89 --- /dev/null +++ b/extensions/blocks/contact-info/editor.scss @@ -0,0 +1,19 @@ +.jetpack-contact-info-block { + padding: 10px 18px; + /* css class added to increase specificity */ + .editor-plain-text.editor-plain-text:focus { + box-shadow: none; + } + + .editor-plain-text { + flex-grow: 1; + min-height: unset; + padding: 0; + box-shadow: none; + font-family: inherit; + font-size: inherit; + color: inherit; + line-height: inherit; + border: none; + } +} diff --git a/extensions/blocks/contact-info/email/edit.js b/extensions/blocks/contact-info/email/edit.js new file mode 100644 index 0000000000000..164b57eb206fa --- /dev/null +++ b/extensions/blocks/contact-info/email/edit.js @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import save from './save'; +import { __ } from '../../../utils/i18n'; +import simpleInput from '../../../utils/simple-input'; + +const EmailEdit = props => { + const { setAttributes } = props; + return simpleInput( 'email', props, __( 'Email' ), save, nextValue => + setAttributes( { email: nextValue } ) + ); +}; + +export default EmailEdit; diff --git a/extensions/blocks/contact-info/email/editor.js b/extensions/blocks/contact-info/email/editor.js new file mode 100644 index 0000000000000..4f9f21a0fde07 --- /dev/null +++ b/extensions/blocks/contact-info/email/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/contact-info/email/index.js b/extensions/blocks/contact-info/email/index.js new file mode 100644 index 0000000000000..8b87f75b7f5a6 --- /dev/null +++ b/extensions/blocks/contact-info/email/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { Path } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import renderMaterialIcon from '../../../utils/render-material-icon'; +import { __, _x } from '../../../utils/i18n'; + +const attributes = { + email: { + type: 'string', + default: '', + }, +}; + +export const name = 'email'; + +export const settings = { + title: __( 'Email Address' ), + description: __( + 'Lets you add an email address with an automatically generated click-to-email link.' + ), + keywords: [ + 'e-mail', // not translatable on purpose + 'email', // not translatable on purpose + _x( 'message', 'block search term' ), + ], + icon: renderMaterialIcon( + <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" /> + ), + category: 'jetpack', + attributes, + edit, + save, + parent: [ 'jetpack/contact-info' ], +}; diff --git a/extensions/blocks/contact-info/email/save.js b/extensions/blocks/contact-info/email/save.js new file mode 100644 index 0000000000000..e0eb0204d1545 --- /dev/null +++ b/extensions/blocks/contact-info/email/save.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import emailValidator from 'email-validator'; +import { Fragment } from '@wordpress/element'; + +const renderEmail = inputText => { + const explodedInput = inputText.split( /(\s+)/ ).map( ( email, i ) => { + // Remove and punctuation from the end of the email address. + const emailToValidate = email.replace( /([.,/#!$%^&*;:{}=\-_`~()\][])+$/g, '' ); + if ( email.indexOf( '@' ) && emailValidator.validate( emailToValidate ) ) { + return email === emailToValidate ? ( + // Email. + <a href={ `mailto:${ email }` } key={ i }> + { email } + </a> + ) : ( + // Email with punctionation. + <Fragment key={ i }> + <a href={ `mailto:${ email }` } key={ i }> + { emailToValidate } + </a> + <Fragment>{ email.slice( -( email.length - emailToValidate.length ) ) }</Fragment> + </Fragment> + ); + } + // Just a plain string. + return <Fragment key={ i }>{ email }</Fragment>; + } ); + return explodedInput; +}; + +const save = ( { attributes: { email }, className } ) => + email && <div className={ className }>{ renderEmail( email ) }</div>; + +export default save; diff --git a/extensions/blocks/contact-info/index.js b/extensions/blocks/contact-info/index.js new file mode 100644 index 0000000000000..da9b43977ac8d --- /dev/null +++ b/extensions/blocks/contact-info/index.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { InnerBlocks } from '@wordpress/editor'; +import { Path } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import renderMaterialIcon from '../../utils/render-material-icon'; +import { __, _x } from '../../utils/i18n'; +import './editor.scss'; +import './style.scss'; +import { name as addressName, settings as addressSettings } from './address/'; +import { name as emailName, settings as emailSettings } from './email/'; +import { name as phoneName, settings as phoneSettings } from './phone/'; + +const attributes = {}; + +const save = ( { className } ) => ( + <div className={ className }> + <InnerBlocks.Content /> + </div> +); + +export const name = 'contact-info'; + +export const settings = { + title: __( 'Contact Info' ), + description: __( + 'Lets you add an email address, phone number, and physical address with improved markup for better SEO results.' + ), + keywords: [ + _x( 'email', 'block search term' ), + _x( 'phone', 'block search term' ), + _x( 'address', 'block search term' ), + ], + icon: renderMaterialIcon( + <Path d="M19 5v14H5V5h14m0-2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 9c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3zm0-4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm6 10H6v-1.53c0-2.5 3.97-3.58 6-3.58s6 1.08 6 3.58V18zm-9.69-2h7.38c-.69-.56-2.38-1.12-3.69-1.12s-3.01.56-3.69 1.12z" /> + ), + category: 'jetpack', + supports: { + align: [ 'wide', 'full' ], + html: false, + }, + attributes, + edit, + save, +}; + +export const childBlocks = [ + { name: addressName, settings: addressSettings }, + { name: emailName, settings: emailSettings }, + { name: phoneName, settings: phoneSettings }, +]; diff --git a/extensions/blocks/contact-info/phone/edit.js b/extensions/blocks/contact-info/phone/edit.js new file mode 100644 index 0000000000000..9be393ff1475f --- /dev/null +++ b/extensions/blocks/contact-info/phone/edit.js @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import save from './save'; +import { __ } from '../../../utils/i18n'; +import simpleInput from '../../../utils/simple-input'; + +const PhoneEdit = props => { + const { setAttributes } = props; + return simpleInput( 'phone', props, __( 'Phone number' ), save, nextValue => + setAttributes( { phone: nextValue } ) + ); +}; + +export default PhoneEdit; diff --git a/extensions/blocks/contact-info/phone/editor.js b/extensions/blocks/contact-info/phone/editor.js new file mode 100644 index 0000000000000..4f9f21a0fde07 --- /dev/null +++ b/extensions/blocks/contact-info/phone/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/contact-info/phone/index.js b/extensions/blocks/contact-info/phone/index.js new file mode 100644 index 0000000000000..5a52f99493cff --- /dev/null +++ b/extensions/blocks/contact-info/phone/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { Path } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import renderMaterialIcon from '../../../utils/render-material-icon'; +import { __, _x } from '../../../utils/i18n'; + +const attributes = { + phone: { + type: 'string', + default: '', + }, +}; + +export const name = 'phone'; + +export const settings = { + title: __( 'Phone Number' ), + description: __( + 'Lets you add a phone number with an automatically generated click-to-call link.' + ), + keywords: [ + _x( 'mobile', 'block search term' ), + _x( 'telephone', 'block search term' ), + _x( 'cell', 'block search term' ), + ], + icon: renderMaterialIcon( + <Path d="M6.54 5c.06.89.21 1.76.45 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79h1.51m9.86 12.02c.85.24 1.72.39 2.6.45v1.49c-1.32-.09-2.59-.35-3.8-.75l1.2-1.19M7.5 3H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.49c0-.55-.45-1-1-1-1.24 0-2.45-.2-3.57-.57-.1-.04-.21-.05-.31-.05-.26 0-.51.1-.71.29l-2.2 2.2c-2.83-1.45-5.15-3.76-6.59-6.59l2.2-2.2c.28-.28.36-.67.25-1.02C8.7 6.45 8.5 5.25 8.5 4c0-.55-.45-1-1-1z" /> + ), + category: 'jetpack', + attributes, + parent: [ 'jetpack/contact-info' ], + edit, + save, +}; diff --git a/extensions/blocks/contact-info/phone/save.js b/extensions/blocks/contact-info/phone/save.js new file mode 100644 index 0000000000000..50f6791464a06 --- /dev/null +++ b/extensions/blocks/contact-info/phone/save.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ + +export function renderPhone( inputText ) { + const arrayOfNumbers = inputText.match( /\d+\.\d+|\d+\b|\d+(?=\w)/g ); + if ( ! arrayOfNumbers ) { + // No numbers found + return inputText; + } + const indexOfFirstNumber = inputText.indexOf( arrayOfNumbers[ 0 ] ); + + // Assume that eveything after the first number should be part of the phone number. + // care about the first prefix character. + let phoneNumber = indexOfFirstNumber ? inputText.substring( indexOfFirstNumber - 1 ) : inputText; + let prefix = indexOfFirstNumber ? inputText.substring( 0, indexOfFirstNumber ) : ''; + + let justNumber = phoneNumber.replace( /\D/g, '' ); + // Phone numbers starting with + should be part of the number. + if ( /[0-9/+/(]/.test( phoneNumber[ 0 ] ) ) { + // Remove the special character from the prefix so they don't appear twice. + prefix = prefix.slice( 0, -1 ); + // Phone numbers starting with + shoud be part of the number. + if ( phoneNumber[ 0 ] === '+' ) { + justNumber = '+' + justNumber; + } + } else { + // Remove the first character. + phoneNumber = phoneNumber.substring( 1 ); + } + const prefixSpan = prefix.trim() ? ( + <span key="phonePrefix" className="phone-prefix"> + { prefix } + </span> + ) : null; + return [ + prefixSpan, + <a key="phoneNumber" href={ `tel:${ justNumber }` }> + { phoneNumber } + </a>, + ]; +} + +const save = ( { attributes: { phone }, className } ) => + phone && <div className={ className }>{ renderPhone( phone ) }</div>; + +export default save; diff --git a/extensions/blocks/contact-info/style.scss b/extensions/blocks/contact-info/style.scss new file mode 100644 index 0000000000000..8f81ca897b1d9 --- /dev/null +++ b/extensions/blocks/contact-info/style.scss @@ -0,0 +1,3 @@ +.wp-block-jetpack-contact-info { + margin-bottom: 1.5em; +} diff --git a/extensions/blocks/contact-info/view.js b/extensions/blocks/contact-info/view.js new file mode 100644 index 0000000000000..fd92905ca5dd0 --- /dev/null +++ b/extensions/blocks/contact-info/view.js @@ -0,0 +1,5 @@ +/** + * Internal dependencies + */ + +import './style.scss'; diff --git a/extensions/blocks/gif/edit.js b/extensions/blocks/gif/edit.js new file mode 100644 index 0000000000000..96c7c2a914598 --- /dev/null +++ b/extensions/blocks/gif/edit.js @@ -0,0 +1,214 @@ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; +import classNames from 'classnames'; +import { Component, createRef } from '@wordpress/element'; +import { Button, PanelBody, Path, Placeholder, SVG, TextControl } from '@wordpress/components'; +import { InspectorControls, RichText } from '@wordpress/editor'; + +import { icon, title } from './'; + +const GIPHY_API_KEY = 't1PkR1Vq0mzHueIFBvZSZErgFs9NBmYW'; +const INPUT_PROMPT = __( 'Search for a term or paste a Giphy URL' ); + +class GifEdit extends Component { + textControlRef = createRef(); + + state = { + captionFocus: false, + results: null, + }; + + onFormSubmit = event => { + event.preventDefault(); + this.onSubmit(); + }; + + onSubmit = () => { + const { attributes } = this.props; + const { searchText } = attributes; + this.parseSearch( searchText ); + }; + + parseSearch = searchText => { + let giphyID = null; + // If search is hardcoded Giphy URL following this pattern: https://giphy.com/embed/4ZFekt94LMhNK + if ( searchText.indexOf( '//giphy.com/gifs' ) !== -1 ) { + giphyID = this.splitAndLast( this.splitAndLast( searchText, '/' ), '-' ); + } + // If search is hardcoded Giphy URL following this patterh: http://i.giphy.com/4ZFekt94LMhNK.gif + if ( searchText.indexOf( '//i.giphy.com' ) !== -1 ) { + giphyID = this.splitAndLast( searchText, '/' ).replace( '.gif', '' ); + } + // https://media.giphy.com/media/gt0hYzKlMpfOg/giphy.gif + const match = searchText.match( + /http[s]?:\/\/media.giphy.com\/media\/([A-Za-z0-9\-.]+)\/giphy.gif/ + ); + if ( match ) { + giphyID = match[ 1 ]; + } + if ( giphyID ) { + return this.fetch( this.urlForId( giphyID ) ); + } + + return this.fetch( this.urlForSearch( searchText ) ); + }; + + urlForSearch = searchText => { + return `https://api.giphy.com/v1/gifs/search?q=${ encodeURIComponent( + searchText + ) }&api_key=${ encodeURIComponent( GIPHY_API_KEY ) }&limit=10`; + }; + + urlForId = giphyId => { + return `https://api.giphy.com/v1/gifs/${ encodeURIComponent( + giphyId + ) }?api_key=${ encodeURIComponent( GIPHY_API_KEY ) }`; + }; + + splitAndLast = ( array, delimiter ) => { + const split = array.split( delimiter ); + return split[ split.length - 1 ]; + }; + + fetch = url => { + const xhr = new XMLHttpRequest(); + xhr.open( 'GET', url ); + xhr.onload = () => { + if ( xhr.status === 200 ) { + const res = JSON.parse( xhr.responseText ); + // If there is only one result, Giphy's API does not return an array. + // The following statement normalizes the data into an array with one member in this case. + const results = typeof res.data.images !== 'undefined' ? [ res.data ] : res.data; + const giphyData = results[ 0 ]; + // No results + if ( ! giphyData.images ) { + return; + } + this.setState( { results }, () => { + this.selectGiphy( giphyData ); + } ); + } else { + // Error handling TK + } + }; + xhr.send(); + }; + + selectGiphy = giphy => { + const { setAttributes } = this.props; + const calculatedPaddingTop = Math.floor( + ( giphy.images.original.height / giphy.images.original.width ) * 100 + ); + const paddingTop = `${ calculatedPaddingTop }%`; + const giphyUrl = giphy.embed_url; + setAttributes( { giphyUrl, paddingTop } ); + }; + + setFocus = () => { + this.textControlRef.current.querySelector( 'input' ).focus(); + this.setState( { captionFocus: false } ); + }; + + hasSearchText = () => { + const { attributes } = this.props; + const { searchText } = attributes; + return searchText && searchText.length > 0; + }; + + thumbnailClicked = thumbnail => { + this.selectGiphy( thumbnail ); + }; + + render() { + const { attributes, className, isSelected, setAttributes } = this.props; + const { align, caption, giphyUrl, searchText, paddingTop } = attributes; + const { captionFocus, results } = this.state; + const style = { paddingTop }; + const classes = classNames( className, `align${ align }` ); + const inputFields = ( + <form + className="wp-block-jetpack-gif_input-container" + onSubmit={ this.onFormSubmit } + ref={ this.textControlRef } + > + <TextControl + className="wp-block-jetpack-gif_input" + label={ INPUT_PROMPT } + placeholder={ INPUT_PROMPT } + onChange={ value => setAttributes( { searchText: value } ) } + value={ searchText } + /> + <Button isLarge onClick={ this.onSubmit }> + { __( 'Search' ) } + </Button> + </form> + ); + return ( + <div className={ classes }> + <InspectorControls> + <PanelBody className="components-panel__body-gif-branding"> + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 202 22"> + <Path d="M4.6 5.9H0v10h1.6v-3.1h3c4.8 0 4.8-6.9 0-6.9zm0 5.4h-3v-4h3c2.6.1 2.6 4 0 4zM51.2 12.3c2-.3 2.7-1.7 2.7-3.1 0-1.7-1.2-3.3-3.5-3.3h-4.6v10h1.6v-3.4h2.1l3 3.4h1.9l-.2-.3-3-3.3zM47.4 11V7.4h3c1.3 0 1.9.9 1.9 1.8s-.6 1.8-1.9 1.8h-3zM30.6 13.6L28 5.9h-1.1l-2.5 7.7-2.6-7.7H20l3.7 10H25l1.4-3.5L27.5 9l1.1 3.4 1.3 3.5h1.4l3.5-10h-1.7z" /> + <Path d="M14.4 5.7c-3 0-5.1 2.2-5.1 5.2 0 2.6 1.6 5.1 5.1 5.1 3.5 0 5.1-2.5 5.1-5.2-.1-2.6-1.7-5.1-5.1-5.1zm-.1 8.9c-2.5 0-3.5-1.9-3.5-3.7 0-2.2 1.2-3.8 3.5-3.8 2.4 0 3.5 2 3.5 3.8.1 2-1 3.7-3.5 3.7zM57.7 11.6h5.5v-1.5h-5.5V7.4h5.7V5.9h-7.3v10h7.3v-1.6h-5.7zM38 14.3v-2.7h5.5v-1.5H38V7.4h5.7V5.9h-7.3v10h7.3v-1.6zM93 10.3l-2.7-4.4h-1.9V6l3.8 5.8v4.1h1.6v-4.1l4-5.8v-.1h-2zM69.3 5.9h-3.8v10h3.8c3.5 0 5.1-2.5 5-5.1-.1-2.5-1.6-4.9-5-4.9zm0 8.4h-2.2V7.4h2.2c2.3 0 3.4 1.7 3.4 3.4s-1 3.5-3.4 3.5zM86.3 10.7c.9-.4 1.4-1.1 1.4-2 0-2-1.5-2.8-3.4-2.8h-4.6v10h4.6c2 0 3.7-.7 3.7-2.8 0-.8-.5-2-1.7-2.4zm-5-3.4h3c1.2 0 1.8.7 1.8 1.4 0 .8-.6 1.3-1.8 1.3h-3V7.3zm3 7.1h-3v-2.9h3c.9 0 2.1.5 2.1 1.6 0 1-1.2 1.3-2.1 1.3zM113.9 13.3h5.3V16c-1.2.9-2.9 1.1-4 1.1-4.2 0-5.6-3.3-5.6-6 0-4.1 2.2-6.1 5.6-6.1 1.4 0 3.2.4 4.8 1.8l3.4-3.4C120.7.6 118.1 0 115.2 0c-7.8 0-11.4 5.6-11.4 11s3.1 10.9 11.4 10.9c4 0 7.6-1.4 8.9-4.1V8.6h-10.2v4.7zM171.9 8.5h-7.4V.6h-5.9v20.8h5.9v-7.8h7.4v7.8h5.9V.6h-5.9zM195.1.6l-4.5 7.1-4.3-7.1h-6.6v.2l7.9 12.3v8.3h5.9v-8.3L201.8.9V.6zM127.4.6h5.9v20.8h-5.9zM147.6.6h-10.1v20.8h5.9v-5.6h4.2c5.6-.1 8.3-3.4 8.3-7.6.1-4.1-2.7-7.6-8.3-7.6zm0 10.2h-4.2V5.6h4.2c1.6 0 2.5 1.2 2.5 2.6 0 1.4-.9 2.6-2.5 2.6z" /> + </SVG> + </PanelBody> + </InspectorControls> + { ! giphyUrl ? ( + <Placeholder className="wp-block-jetpack-gif_placeholder" icon={ icon } label={ title }> + { inputFields } + </Placeholder> + ) : ( + <figure> + { isSelected && inputFields } + { isSelected && results && results.length > 1 && ( + <div className="wp-block-jetpack-gif_thumbnails-container"> + { results.map( thumbnail => { + const thumbnailStyle = { + backgroundImage: `url(${ thumbnail.images.downsized_still.url })`, + }; + return ( + <button + className="wp-block-jetpack-gif_thumbnail-container" + key={ thumbnail.id } + onClick={ () => { + this.thumbnailClicked( thumbnail ); + } } + style={ thumbnailStyle } + /> + ); + } ) } + </div> + ) } + <div className="wp-block-jetpack-gif-wrapper" style={ style }> + <div + className="wp-block-jetpack-gif_cover" + onClick={ this.setFocus } + onKeyDown={ this.setFocus } + role="button" + tabIndex="0" + /> + <iframe src={ giphyUrl } title={ searchText } /> + </div> + { ( ! RichText.isEmpty( caption ) || isSelected ) && !! giphyUrl && ( + <RichText + className="wp-block-jetpack-gif-caption gallery-caption" + inlineToolbar + isSelected={ captionFocus } + unstableOnFocus={ () => { + this.setState( { captionFocus: true } ); + } } + onChange={ value => setAttributes( { caption: value } ) } + placeholder={ __( 'Write caption…' ) } + tagName="figcaption" + value={ caption } + /> + ) } + </figure> + ) } + </div> + ); + } +} +export default GifEdit; diff --git a/extensions/blocks/gif/editor.js b/extensions/blocks/gif/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/gif/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/gif/editor.scss b/extensions/blocks/gif/editor.scss new file mode 100644 index 0000000000000..6505083f85efc --- /dev/null +++ b/extensions/blocks/gif/editor.scss @@ -0,0 +1,84 @@ +.wp-block-jetpack-gif { + figure { + transition: padding-top 125ms ease-in-out; + } + .components-base-control__field { + text-align: center; + } + .wp-block-jetpack-gif_cover { + background: none; + border: none; + height: 100%; + left: 0; + margin: 0; + padding: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + &:focus { + outline: none; + } + } + .wp-block-jetpack-gif_input-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + margin: 0 auto; + max-width: 400px; + width: 100%; + z-index: 1; + .components-base-control__label { + height: 0; + margin: 0; + text-indent: -9999px; + } + } + .wp-block-jetpack-gif_input { + flex-grow: 1; + margin-right: 0.5em; + } + .wp-block-jetpack-gif_thumbnails-container { + display: flex; + margin: -2px 0 2px 0; + margin-left: calc( -4px / 2 ); + overflow-x: auto; + width: calc( 100% + 4px ); + &::-webkit-scrollbar { + display: none; + } + } + .wp-block-jetpack-gif_thumbnail-container { + align-items: center; + background-size: cover; + background-repeat: no-repeat; + background-position: 50% 50%; + border: none; + border-radius: 3px; + cursor: pointer; + display: flex; + justify-content: center; + margin: 2px; + padding: 0; + padding-bottom: calc( 100% / 10 - 4px ); + width: calc( 100% / 10 - 4px ); + &:hover { + box-shadow: 0 0 0 1px #555d66; + } + &:focus { + box-shadow: 0 0 0 2px #00a0d2; + outline: 0; + } + } +} +.components-panel__body-gif-branding { + svg { + display: block; + margin: 0 auto; + max-width: 200px; + } + svg path { + fill: #ccc; + } +} diff --git a/extensions/blocks/gif/index.js b/extensions/blocks/gif/index.js new file mode 100644 index 0000000000000..045d1618173f9 --- /dev/null +++ b/extensions/blocks/gif/index.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import { __ } from '../../utils/i18n'; + +// Ordering is important! Editor overrides style! +import './style.scss'; +import './editor.scss'; + +export const name = 'gif'; +export const title = __( 'GIF' ); + +export const icon = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M18 13v7H4V6h5.02c.05-.71.22-1.38.48-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-5l-2-2zm-1.5 5h-11l2.75-3.53 1.96 2.36 2.75-3.54L16.5 18zm2.8-9.11c.44-.7.7-1.51.7-2.39C20 4.01 17.99 2 15.5 2S11 4.01 11 6.5s2.01 4.5 4.49 4.5c.88 0 1.7-.26 2.39-.7L21 13.42 22.42 12 19.3 8.89zM15.5 9C14.12 9 13 7.88 13 6.5S14.12 4 15.5 4 18 5.12 18 6.5 16.88 9 15.5 9z" /> + </SVG> +); + +export const settings = { + title, + icon, + category: 'jetpack', + keywords: [ __( 'animated' ), __( 'giphy' ), __( 'image' ) ], + description: __( 'Search for and insert an animated image.' ), + attributes: { + align: { + type: 'string', + default: 'center', + }, + caption: { + type: 'string', + }, + giphyUrl: { + type: 'string', + }, + searchText: { + type: 'string', + }, + paddingTop: { + type: 'string', + default: '56.2%', + }, + }, + supports: { + html: false, + align: true, + }, + edit, + save: () => null, +}; diff --git a/extensions/blocks/gif/style.scss b/extensions/blocks/gif/style.scss new file mode 100644 index 0000000000000..2e7b057a79ac4 --- /dev/null +++ b/extensions/blocks/gif/style.scss @@ -0,0 +1,38 @@ +.wp-block-jetpack-gif { + clear: both; + margin: 0 0 20px; + figure { + margin: 0; + position: relative; + width: 100%; + } + iframe { + border: 0; + left: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + &.aligncenter { + text-align: center; + } + &.alignright, + &.alignleft { + min-width: 300px; + } + // Mirroring Gutenberg caption-style mixin: https://github.com/WordPress/gutenberg/blob/master/assets/stylesheets/_mixins.scss#L312-L318 + .wp-block-jetpack-gif-caption { + margin-top: 0.5em; + margin-bottom: 1em; + color: #555d66; + text-align: center; + } + .wp-block-jetpack-gif-wrapper { + height: 0; + margin: 0; + padding: calc( 56.2% + 12px ) 0 0 0; + position: relative; + width: 100%; + } +} diff --git a/extensions/blocks/gif/view.js b/extensions/blocks/gif/view.js new file mode 100644 index 0000000000000..6a6dda31712c5 --- /dev/null +++ b/extensions/blocks/gif/view.js @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +import './style.scss'; diff --git a/extensions/blocks/mailchimp/edit.js b/extensions/blocks/mailchimp/edit.js new file mode 100644 index 0000000000000..8941092eb831f --- /dev/null +++ b/extensions/blocks/mailchimp/edit.js @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '../../utils/i18n'; +import classnames from 'classnames'; +import SubmitButton from '../../utils/submit-button'; +import { + Button, + ExternalLink, + PanelBody, + Placeholder, + Spinner, + TextControl, + withNotices, +} from '@wordpress/components'; +import { InspectorControls, RichText } from '@wordpress/editor'; +import { Fragment, Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { icon } from '.'; + +const API_STATE_LOADING = 0; +const API_STATE_CONNECTED = 1; +const API_STATE_NOTCONNECTED = 2; + +const NOTIFICATION_PROCESSING = 'processing'; +const NOTIFICATION_SUCCESS = 'success'; +const NOTIFICATION_ERROR = 'error'; + +class MailchimpSubscribeEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + audition: null, + connected: API_STATE_LOADING, + connectURL: null, + }; + this.timeout = null; + } + + componentDidMount = () => { + this.apiCall(); + }; + + onError = message => { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + }; + + apiCall = () => { + const path = '/wpcom/v2/mailchimp'; + const method = 'GET'; + const fetch = { path, method }; + apiFetch( fetch ).then( + result => { + const connectURL = result.connect_url; + const connected = + result.code === 'connected' ? API_STATE_CONNECTED : API_STATE_NOTCONNECTED; + this.setState( { connected, connectURL } ); + }, + result => { + const connectURL = null; + const connected = API_STATE_NOTCONNECTED; + this.setState( { connected, connectURL } ); + this.onError( result.message ); + } + ); + }; + + auditionNotification = notification => { + this.setState( { audition: notification } ); + if ( this.timeout ) { + clearTimeout( this.timeout ); + } + this.timeout = setTimeout( this.clearAudition, 3000 ); + }; + + clearAudition = () => { + this.setState( { audition: null } ); + }; + + updateProcessingText = processingLabel => { + const { setAttributes } = this.props; + setAttributes( { processingLabel } ); + this.auditionNotification( NOTIFICATION_PROCESSING ); + }; + + updateSuccessText = successLabel => { + const { setAttributes } = this.props; + setAttributes( { successLabel } ); + this.auditionNotification( NOTIFICATION_SUCCESS ); + }; + + updateErrorText = errorLabel => { + const { setAttributes } = this.props; + setAttributes( { errorLabel } ); + this.auditionNotification( NOTIFICATION_ERROR ); + }; + + updateEmailPlaceholder = emailPlaceholder => { + const { setAttributes } = this.props; + setAttributes( { emailPlaceholder } ); + this.clearAudition(); + }; + + labelForAuditionType = audition => { + const { attributes } = this.props; + const { processingLabel, successLabel, errorLabel } = attributes; + if ( audition === NOTIFICATION_PROCESSING ) { + return processingLabel; + } else if ( audition === NOTIFICATION_SUCCESS ) { + return successLabel; + } else if ( audition === NOTIFICATION_ERROR ) { + return errorLabel; + } + return null; + }; + + roleForAuditionType = audition => { + if ( audition === NOTIFICATION_ERROR ) { + return 'alert'; + } + return 'status'; + }; + + render = () => { + const { attributes, className, notices, noticeUI, setAttributes } = this.props; + const { audition, connected, connectURL } = this.state; + const { emailPlaceholder, consentText, processingLabel, successLabel, errorLabel } = attributes; + const classPrefix = 'wp-block-jetpack-mailchimp_'; + const waiting = ( + <Placeholder icon={ icon } notices={ notices }> + <Spinner /> + </Placeholder> + ); + const placeholder = ( + <Placeholder icon={ icon } label={ __( 'Mailchimp' ) } notices={ notices }> + <div className="components-placeholder__instructions"> + { __( + 'You need to connect your Mailchimp account and choose a list in order to start collecting Email subscribers.' + ) } + <br /> + <br /> + <Button isDefault isLarge href={ connectURL } target="_blank"> + { __( 'Set up Mailchimp form' ) } + </Button> + <br /> + <br /> + <Button isLink onClick={ this.apiCall }> + { __( 'Re-check Connection' ) } + </Button> + </div> + </Placeholder> + ); + const inspectorControls = ( + <InspectorControls> + <PanelBody title={ __( 'Text Elements' ) }> + <TextControl + label={ __( 'Email Placeholder' ) } + value={ emailPlaceholder } + onChange={ this.updateEmailPlaceholder } + /> + </PanelBody> + <PanelBody title={ __( 'Notifications' ) }> + <TextControl + label={ __( 'Processing text' ) } + value={ processingLabel } + onChange={ this.updateProcessingText } + /> + <TextControl + label={ __( 'Success text' ) } + value={ successLabel } + onChange={ this.updateSuccessText } + /> + <TextControl + label={ __( 'Error text' ) } + value={ errorLabel } + onChange={ this.updateErrorText } + /> + </PanelBody> + <PanelBody title={ __( 'Mailchimp Connection' ) }> + <ExternalLink href={ connectURL }>{ __( 'Manage Connection' ) }</ExternalLink> + </PanelBody> + </InspectorControls> + ); + const blockClasses = classnames( className, { + [ `${ classPrefix }notication-audition` ]: audition, + } ); + const blockContent = ( + <div className={ blockClasses }> + <TextControl + aria-label={ emailPlaceholder } + className="wp-block-jetpack-mailchimp_text-input" + disabled + onChange={ () => false } + placeholder={ emailPlaceholder } + title={ __( 'You can edit the email placeholder in the sidebar.' ) } + type="email" + /> + <SubmitButton { ...this.props } /> + <RichText + tagName="p" + placeholder={ __( 'Write consent text' ) } + value={ consentText } + onChange={ value => setAttributes( { consentText: value } ) } + inlineToolbar + /> + { audition && ( + <div + className={ `${ classPrefix }notification ${ classPrefix }${ audition }` } + role={ this.roleForAuditionType( audition ) } + > + { this.labelForAuditionType( audition ) } + </div> + ) } + </div> + ); + return ( + <Fragment> + { noticeUI } + { connected === API_STATE_LOADING && waiting } + { connected === API_STATE_NOTCONNECTED && placeholder } + { connected === API_STATE_CONNECTED && inspectorControls } + { connected === API_STATE_CONNECTED && blockContent } + </Fragment> + ); + }; +} + +export default withNotices( MailchimpSubscribeEdit ); diff --git a/extensions/blocks/mailchimp/editor.js b/extensions/blocks/mailchimp/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/mailchimp/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/mailchimp/editor.scss b/extensions/blocks/mailchimp/editor.scss new file mode 100644 index 0000000000000..84de75481ad0a --- /dev/null +++ b/extensions/blocks/mailchimp/editor.scss @@ -0,0 +1,29 @@ +@import './view.scss'; + +.wp-block-jetpack-mailchimp { + + .wp-block-jetpack-mailchimp_notification { + display: block; + } + + .editor-rich-text__inline-toolbar { + pointer-events: none; + .components-toolbar { + pointer-events: all; + } + } + + // Hide everything else except notification when modifying notification labels + &.wp-block-jetpack-mailchimp_notication-audition > *:not( .wp-block-jetpack-mailchimp_notification ) { + display: none; + } + + .wp-block-jetpack-mailchimp_text-input, .jetpack-submit-button { + margin-bottom: 1.5rem; + } + + .wp-block-button .wp-block-button__link { + margin-top: 0; + } + +} diff --git a/extensions/blocks/mailchimp/index.js b/extensions/blocks/mailchimp/index.js new file mode 100644 index 0000000000000..a14f2f5d4cbbb --- /dev/null +++ b/extensions/blocks/mailchimp/index.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '../../utils/i18n'; +import edit from './edit'; +import './editor.scss'; + +export const name = 'mailchimp'; + +export const icon = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z" /> + </SVG> +); + +export const settings = { + title: __( 'Mailchimp' ), + icon, + description: __( 'A form enabling readers to join a Mailchimp list.' ), + category: 'jetpack', + keywords: [ + _x( 'email', 'block search term' ), + _x( 'subscription', 'block search term' ), + _x( 'newsletter', 'block search term' ), + ], + attributes: { + emailPlaceholder: { + type: 'string', + default: __( 'Enter your email' ), + }, + submitButtonText: { + type: 'string', + default: __( 'Join my email list' ), + }, + customBackgroundButtonColor: { + type: 'string', + }, + customTextButtonColor: { + type: 'string', + }, + consentText: { + type: 'string', + default: __( + 'By clicking submit, you agree to share your email address with the site owner and Mailchimp to receive marketing, updates, and other emails from the site owner. Use the unsubscribe link in those emails to opt out at any time.' + ), + }, + processingLabel: { + type: 'string', + default: __( 'Processing…' ), + }, + successLabel: { + type: 'string', + default: __( "Success! You're on the list." ), + }, + errorLabel: { + type: 'string', + default: __( + "Whoops! There was an error and we couldn't process your subscription. Please reload the page and try again." + ), + }, + }, + edit, + save: () => null, +}; diff --git a/extensions/blocks/mailchimp/view.js b/extensions/blocks/mailchimp/view.js new file mode 100644 index 0000000000000..a2ec768045560 --- /dev/null +++ b/extensions/blocks/mailchimp/view.js @@ -0,0 +1,91 @@ +/** + * Internal dependencies + */ +import emailValidator from 'email-validator'; + +/** + * Internal dependencies + */ +import './view.scss'; + +const blockClassName = 'wp-block-jetpack-mailchimp'; + +function fetchSubscription( blogId, email ) { + const url = + 'https://public-api.wordpress.com/rest/v1.1/sites/' + + encodeURIComponent( blogId ) + + '/email_follow/subscribe?email=' + + encodeURIComponent( email ); + return new Promise( function( resolve, reject ) { + const xhr = new XMLHttpRequest(); + xhr.open( 'GET', url ); + xhr.onload = function() { + if ( xhr.status === 200 ) { + const res = JSON.parse( xhr.responseText ); + resolve( res ); + } else { + const res = JSON.parse( xhr.responseText ); + reject( res ); + } + }; + xhr.send(); + } ); +} + +function activateSubscription( block, blogId ) { + const form = block.querySelector( 'form' ); + const errorClass = 'error'; + const processingEl = block.querySelector( '.' + blockClassName + '_processing' ); + const errorEl = block.querySelector( '.' + blockClassName + '_error' ); + const successEl = block.querySelector( '.' + blockClassName + '_success' ); + form.addEventListener( 'submit', e => { + e.preventDefault(); + const emailField = form.querySelector( 'input' ); + emailField.classList.remove( errorClass ); + const email = emailField.value; + if ( ! emailValidator.validate( email ) ) { + emailField.classList.add( errorClass ); + return; + } + block.classList.add( 'is-processing' ); + processingEl.classList.add( 'is-visible' ); + fetchSubscription( blogId, email ).then( + response => { + processingEl.classList.remove( 'is-visible' ); + if ( response.error && response.error !== 'member_exists' ) { + errorEl.classList.add( 'is-visible' ); + } else { + successEl.classList.add( 'is-visible' ); + } + }, + () => { + processingEl.classList.remove( 'is-visible' ); + errorEl.classList.add( 'is-visible' ); + } + ); + } ); +} + +const initializeMailchimpBlocks = () => { + const mailchimpBlocks = Array.from( document.querySelectorAll( '.' + blockClassName ) ); + mailchimpBlocks.forEach( block => { + const blog_id = block.getAttribute( 'data-blog-id' ); + try { + activateSubscription( block, blog_id ); + } catch ( err ) { + if ( 'production' !== process.env.NODE_ENV ) { + // eslint-disable-next-line no-console + console.error( err ); + } + } + } ); +}; + +if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) { + // `DOMContentLoaded` may fire before the script has a chance to run + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', initializeMailchimpBlocks ); + } else { + initializeMailchimpBlocks(); + } +} diff --git a/extensions/blocks/mailchimp/view.scss b/extensions/blocks/mailchimp/view.scss new file mode 100644 index 0000000000000..56b852e28ffaa --- /dev/null +++ b/extensions/blocks/mailchimp/view.scss @@ -0,0 +1,32 @@ +.wp-block-jetpack-mailchimp { + + &.is-processing { + form { + display: none; + } + } + + .wp-block-jetpack-mailchimp_notification { + display: none; + margin-bottom: 1.5em; + padding: 0.75em; + &.is-visible { + display: block; + } + + &.wp-block-jetpack-mailchimp_error { + background-color: $muriel-hot-red-500; + color: #fff; + } + + &.wp-block-jetpack-mailchimp_processing { + background-color: rgba( 0, 0, 0, 0.025 ); + } + + &.wp-block-jetpack-mailchimp_success { + background-color: $muriel-hot-green-500; + color: #fff; + } + } + +} diff --git a/extensions/blocks/map/add-point/index.js b/extensions/blocks/map/add-point/index.js new file mode 100644 index 0000000000000..dc08337d98afc --- /dev/null +++ b/extensions/blocks/map/add-point/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { Button, Dashicon, Popover } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import LocationSearch from '../location-search'; +import { __ } from '../../../utils/i18n'; +import './style.scss'; + +export class AddPoint extends Component { + render() { + const { onClose, onAddPoint, onError, apiKey } = this.props; + return ( + <Button className="component__add-point"> + { __( 'Add marker' ) } + <Popover className="component__add-point__popover"> + <Button className="component__add-point__close" onClick={ onClose }> + <Dashicon icon="no" /> + </Button> + <LocationSearch + onAddPoint={ onAddPoint } + label={ __( 'Add a location' ) } + apiKey={ apiKey } + onError={ onError } + /> + </Popover> + </Button> + ); + } +} + +AddPoint.defaultProps = { + onAddPoint: () => {}, + onClose: () => {}, + onError: () => {}, +}; + +export default AddPoint; diff --git a/extensions/blocks/map/add-point/oval.svg b/extensions/blocks/map/add-point/oval.svg new file mode 100644 index 0000000000000..cb149ec47cc3d --- /dev/null +++ b/extensions/blocks/map/add-point/oval.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="32px" height="38px" viewBox="0 0 32 38" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 52.1 (67048) - http://www.bohemiancoding.com/sketch --> + <title>Oval Copy</title> + <desc>Created with Sketch.</desc> + <defs> + <path d="M119,136 C119,136 135,124.692424 135,114 C135,103.307576 127.836556,98 119,98 C110.163444,98 103,103.307576 103,114 C103,124.692424 119,136 119,136 Z" id="path-1"></path> + <mask id="mask-2" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="32" height="38" fill="white"> + <use xlink:href="#path-1"></use> + </mask> + </defs> + <g id="Map-Block" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-dasharray="4"> + <g id="Revised-01-Placeholder-Copy" transform="translate(-496.000000, -376.000000)" stroke="#444444" stroke-width="4"> + <g id="Group" transform="translate(393.000000, 278.000000)"> + <use id="Oval-Copy" mask="url(#mask-2)" xlink:href="#path-1"></use> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/extensions/blocks/map/add-point/style.scss b/extensions/blocks/map/add-point/style.scss new file mode 100644 index 0000000000000..9cce943eb3831 --- /dev/null +++ b/extensions/blocks/map/add-point/style.scss @@ -0,0 +1,46 @@ + +.component__add-point { + position: absolute; + left: 50%; + top: 50%; + width: 32px; + height: 38px; + margin-top: -19px; + margin-left: -16px; + background-image: url( ./oval.svg ); + background-repeat: no-repeat; + text-indent: -9999px; + box-shadow: none; + background-color: transparent; + &.components-button:not( :disabled ):not( [aria-disabled='true'] ):focus { + background-color: transparent; + box-shadow: none; + } + &:focus, + &:active { + background-color: transparent; + box-shadow: none; + } +} +.component__add-point__popover { + .components-button:not( :disabled ):not( [aria-disabled='true'] ):focus { + background-color: transparent; + box-shadow: none; + } + .components-popover__content { + padding: 0.1rem; + } + .components-location-search { + margin: 0.5rem; + } +} +.component__add-point__close { + margin: 0; + padding: 0; + border: none; + box-shadow: none; + float: right; + path { + color: #aaa; + } +} diff --git a/extensions/blocks/map/component.js b/extensions/blocks/map/component.js new file mode 100644 index 0000000000000..efd19c3a0d411 --- /dev/null +++ b/extensions/blocks/map/component.js @@ -0,0 +1,332 @@ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; +import { assign, debounce, get } from 'lodash'; +import { Button, Dashicon, TextareaControl, TextControl } from '@wordpress/components'; +import { Children, Component, createRef, Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import MapMarker from './map-marker/'; +import InfoWindow from './info-window/'; +import { mapboxMapFormatter } from './mapbox-map-formatter/'; + +export class Map extends Component { + // Lifecycle + constructor() { + super( ...arguments ); + + this.state = { + map: null, + fit_to_bounds: false, + loaded: false, + mapboxgl: null, + }; + + // Refs + this.mapRef = createRef(); + + // Debouncers + this.debouncedSizeMap = debounce( this.sizeMap, 250 ); + } + render() { + const { points, admin, children, markerColor } = this.props; + const { map, activeMarker, mapboxgl } = this.state; + const { onMarkerClick, deleteActiveMarker, updateActiveMarker } = this; + const currentPoint = get( activeMarker, 'props.point' ) || {}; + const { title, caption } = currentPoint; + const addPoint = Children.map( children, child => { + const tagName = get( child, 'props.tagName' ); + if ( 'AddPoint' === tagName ) { + return child; + } + } ); + const mapMarkers = + map && + mapboxgl && + points.map( ( point, index ) => { + return ( + <MapMarker + key={ index } + point={ point } + index={ index } + map={ map } + mapboxgl={ mapboxgl } + markerColor={ markerColor } + onClick={ onMarkerClick } + /> + ); + } ); + const infoWindow = mapboxgl && ( + <InfoWindow + activeMarker={ activeMarker } + map={ map } + mapboxgl={ mapboxgl } + unsetActiveMarker={ () => this.setState( { activeMarker: null } ) } + > + { activeMarker && admin && ( + <Fragment> + <TextControl + label={ __( 'Marker Title' ) } + value={ title } + onChange={ value => updateActiveMarker( { title: value } ) } + /> + <TextareaControl + className="wp-block-jetpack-map__marker-caption" + label={ __( 'Marker Caption' ) } + value={ caption } + rows="2" + tag="textarea" + onChange={ value => updateActiveMarker( { caption: value } ) } + /> + <Button onClick={ deleteActiveMarker } className="wp-block-jetpack-map__delete-btn"> + <Dashicon icon="trash" size="15" /> { __( 'Delete Marker' ) } + </Button> + </Fragment> + ) } + + { activeMarker && ! admin && ( + <Fragment> + <h3>{ title }</h3> + <p>{ caption }</p> + </Fragment> + ) } + </InfoWindow> + ); + return ( + <Fragment> + <div className="wp-block-jetpack-map__gm-container" ref={ this.mapRef }> + { mapMarkers } + </div> + { infoWindow } + { addPoint } + </Fragment> + ); + } + componentDidMount() { + const { apiKey } = this.props; + if ( apiKey ) { + this.loadMapLibraries(); + } + } + componentWillUnmount() { + this.debouncedSizeMap.cancel(); + } + componentDidUpdate( prevProps ) { + const { apiKey, children, points, mapStyle, mapDetails } = this.props; + const { map } = this.state; + if ( apiKey && apiKey.length > 0 && apiKey !== prevProps.apiKey ) { + this.loadMapLibraries(); + } + // If the user has just clicked to show the Add Point component, hide info window. + // AddPoint is the only possible child. + if ( children !== prevProps.children && children !== false ) { + this.clearCurrentMarker(); + } + if ( points !== prevProps.points ) { + this.setBoundsByMarkers(); + } + if ( points.length !== prevProps.points.length ) { + this.clearCurrentMarker(); + } + if ( mapStyle !== prevProps.mapStyle || mapDetails !== prevProps.mapDetails ) { + map.setStyle( this.getMapStyle() ); + } + } + /* Event handling */ + onMarkerClick = marker => { + const { onMarkerClick } = this.props; + this.setState( { activeMarker: marker } ); + onMarkerClick(); + }; + onMapClick = () => { + this.setState( { activeMarker: null } ); + }; + clearCurrentMarker = () => { + this.setState( { activeMarker: null } ); + }; + updateActiveMarker = updates => { + const { points } = this.props; + const { activeMarker } = this.state; + const { index } = activeMarker.props; + const newPoints = points.slice( 0 ); + + assign( newPoints[ index ], updates ); + this.props.onSetPoints( newPoints ); + }; + deleteActiveMarker = () => { + const { points } = this.props; + const { activeMarker } = this.state; + const { index } = activeMarker.props; + const newPoints = points.slice( 0 ); + + newPoints.splice( index, 1 ); + this.props.onSetPoints( newPoints ); + this.setState( { activeMarker: null } ); + }; + // Various map functions + sizeMap = () => { + const { map } = this.state; + const mapEl = this.mapRef.current; + const blockWidth = mapEl.offsetWidth; + const maxHeight = window.innerHeight * 0.8; + const blockHeight = Math.min( blockWidth * ( 3 / 4 ), maxHeight ); + mapEl.style.height = blockHeight + 'px'; + map.resize(); + this.setBoundsByMarkers(); + }; + setBoundsByMarkers = () => { + const { zoom, points, onSetZoom } = this.props; + const { map, activeMarker, mapboxgl, zoomControl, boundsSetProgrammatically } = this.state; + if ( ! map ) { + return; + } + // If there are no points at all, there is no data to set bounds to. Abort the function. + if ( ! points.length ) { + return; + } + // If there is an open info window, resizing will probably move the info window which complicates interaction. + if ( activeMarker ) { + return; + } + const bounds = new mapboxgl.LngLatBounds(); + points.forEach( point => { + bounds.extend( [ point.coordinates.longitude, point.coordinates.latitude ] ); + } ); + + // If there are multiple points, zoom is determined by the area they cover, and zoom control is removed. + if ( points.length > 1 ) { + map.fitBounds( bounds, { + padding: { + top: 40, + bottom: 40, + left: 20, + right: 20, + }, + } ); + this.setState( { boundsSetProgrammatically: true } ); + map.removeControl( zoomControl ); + return; + } + // If there is only one point, center map around it. + map.setCenter( bounds.getCenter() ); + + // If the number of markers has just changed from > 1 to 1, set an arbitrary tight zoom, which feels like the original default. + if ( boundsSetProgrammatically ) { + const newZoom = 12; + map.setZoom( newZoom ); + onSetZoom( newZoom ); + } else { + // If there are one (or zero) points, and this is not a recent change, respect user's chosen zoom. + map.setZoom( parseInt( zoom, 10 ) ); + } + map.addControl( zoomControl ); + this.setState( { boundsSetProgrammatically: false } ); + }; + getMapStyle() { + const { mapStyle, mapDetails } = this.props; + return mapboxMapFormatter( mapStyle, mapDetails ); + } + getMapType() { + const { mapStyle } = this.props; + switch ( mapStyle ) { + case 'satellite': + return 'HYBRID'; + case 'terrain': + return 'TERRAIN'; + case 'black_and_white': + default: + return 'ROADMAP'; + } + } + // Script loading, browser geolocation + scriptsLoaded = () => { + const { mapCenter, points } = this.props; + this.setState( { loaded: true } ); + + // If the map has any points, skip geolocation and use what we have. + if ( points.length > 0 ) { + this.initMap( mapCenter ); + return; + } + this.initMap( mapCenter ); + }; + loadMapLibraries() { + const { apiKey } = this.props; + Promise.all( [ + import( /* webpackChunkName: "map/mapbox-gl" */ 'mapbox-gl' ), + import( /* webpackChunkName: "map/mapbox-gl" */ 'mapbox-gl/dist/mapbox-gl.css' ), + ] ).then( ( [ { default: mapboxgl } ] ) => { + mapboxgl.accessToken = apiKey; + this.setState( { mapboxgl: mapboxgl }, this.scriptsLoaded ); + } ); + } + initMap( mapCenter ) { + const { mapboxgl } = this.state; + const { zoom, onMapLoaded, onError, admin } = this.props; + let map = null; + try { + map = new mapboxgl.Map( { + container: this.mapRef.current, + style: this.getMapStyle(), + center: this.googlePoint2Mapbox( mapCenter ), + zoom: parseInt( zoom, 10 ), + pitchWithRotate: false, + attributionControl: false, + dragRotate: false, + } ); + } catch ( e ) { + onError( 'mapbox_error', e.message ); + return; + } + map.on( 'error', e => { + onError( 'mapbox_error', e.error.message ); + } ); + const zoomControl = new mapboxgl.NavigationControl( { + showCompass: false, + showZoom: true, + } ); + map.on( 'zoomend', () => { + this.props.onSetZoom( map.getZoom() ); + } ); + + /* Listen for clicks on the Map background, which hides the current popup. */ + map.getCanvas().addEventListener( 'click', this.onMapClick ); + this.setState( { map, zoomControl }, () => { + this.debouncedSizeMap(); + map.addControl( zoomControl ); + if ( ! admin ) { + map.addControl( new mapboxgl.FullscreenControl() ); + } + this.mapRef.current.addEventListener( 'alignmentChanged', this.debouncedSizeMap ); + map.resize(); + onMapLoaded(); + this.setState( { loaded: true } ); + window.addEventListener( 'resize', this.debouncedSizeMap ); + } ); + } + googlePoint2Mapbox( google_point ) { + const mapCenter = [ + google_point.longitude ? google_point.longitude : 0, + google_point.latitude ? google_point.latitude : 0, + ]; + return mapCenter; + } +} + +Map.defaultProps = { + points: [], + mapStyle: 'default', + zoom: 13, + onSetZoom: () => {}, + onMapLoaded: () => {}, + onMarkerClick: () => {}, + onError: () => {}, + markerColor: 'red', + apiKey: null, + mapCenter: {}, +}; + +export default Map; diff --git a/extensions/blocks/map/edit.js b/extensions/blocks/map/edit.js new file mode 100644 index 0000000000000..54168e1dbd0e3 --- /dev/null +++ b/extensions/blocks/map/edit.js @@ -0,0 +1,282 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { Component, createRef, Fragment } from '@wordpress/element'; +import { + Button, + ButtonGroup, + ExternalLink, + IconButton, + PanelBody, + Placeholder, + Spinner, + TextControl, + ToggleControl, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { + BlockAlignmentToolbar, + BlockControls, + InspectorControls, + PanelColorSettings, +} from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import AddPoint from './add-point'; +import Locations from './locations'; +import Map from './component.js'; +import MapThemePicker from './map-theme-picker'; +import { __ } from '../../utils/i18n'; +import { settings } from './settings.js'; + +const API_STATE_LOADING = 0; +const API_STATE_FAILURE = 1; +const API_STATE_SUCCESS = 2; + +class MapEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + addPointVisibility: false, + apiState: API_STATE_LOADING, + }; + this.mapRef = createRef(); + } + addPoint = point => { + const { attributes, setAttributes } = this.props; + const { points } = attributes; + const newPoints = points.slice( 0 ); + let duplicateFound = false; + points.map( existingPoint => { + if ( existingPoint.id === point.id ) { + duplicateFound = true; + } + } ); + if ( duplicateFound ) { + return; + } + newPoints.push( point ); + setAttributes( { points: newPoints } ); + this.setState( { addPointVisibility: false } ); + }; + updateAlignment = value => { + this.props.setAttributes( { align: value } ); + // Allow one cycle for alignment change to take effect + setTimeout( this.mapRef.current.sizeMap, 0 ); + }; + updateAPIKeyControl = value => { + this.setState( { + apiKeyControl: value, + } ); + }; + updateAPIKey = () => { + const { noticeOperations } = this.props; + const { apiKeyControl } = this.state; + noticeOperations.removeAllNotices(); + apiKeyControl && this.apiCall( apiKeyControl, 'POST' ); + }; + removeAPIKey = () => { + this.apiCall( null, 'DELETE' ); + }; + apiCall( serviceApiKey = null, method = 'GET' ) { + const { noticeOperations } = this.props; + const { apiKey } = this.state; + const path = '/wpcom/v2/service-api-keys/mapbox'; + const fetch = serviceApiKey + ? { path, method, data: { service_api_key: serviceApiKey } } + : { path, method }; + this.setState( { apiRequestOutstanding: true }, () => { + apiFetch( fetch ).then( + result => { + noticeOperations.removeAllNotices(); + this.setState( { + apiState: result.service_api_key ? API_STATE_SUCCESS : API_STATE_FAILURE, + apiKey: result.service_api_key, + apiKeyControl: result.service_api_key, + apiRequestOutstanding: false, + } ); + }, + result => { + this.onError( null, result.message ); + this.setState( { + apiRequestOutstanding: false, + apiKeyControl: apiKey, + } ); + } + ); + } ); + } + componentDidMount() { + this.apiCall(); + } + onError = ( code, message ) => { + const { noticeOperations } = this.props; + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + }; + render() { + const { className, setAttributes, attributes, noticeUI, notices } = this.props; + const { mapStyle, mapDetails, points, zoom, mapCenter, markerColor, align } = attributes; + const { + addPointVisibility, + apiKey, + apiKeyControl, + apiState, + apiRequestOutstanding, + } = this.state; + const inspectorControls = ( + <Fragment> + <BlockControls> + <BlockAlignmentToolbar + value={ align } + onChange={ this.updateAlignment } + controls={ [ 'center', 'wide', 'full' ] } + /> + <Toolbar> + <IconButton + icon={ settings.markerIcon } + label="Add a marker" + onClick={ () => this.setState( { addPointVisibility: true } ) } + /> + </Toolbar> + </BlockControls> + <InspectorControls> + <PanelBody title={ __( 'Map Theme' ) }> + <MapThemePicker + value={ mapStyle } + onChange={ value => setAttributes( { mapStyle: value } ) } + options={ settings.mapStyleOptions } + /> + <ToggleControl + label={ __( 'Show street names' ) } + checked={ mapDetails } + onChange={ value => setAttributes( { mapDetails: value } ) } + /> + </PanelBody> + <PanelColorSettings + title={ __( 'Colors' ) } + initialOpen={ true } + colorSettings={ [ + { + value: markerColor, + onChange: value => setAttributes( { markerColor: value } ), + label: 'Marker Color', + }, + ] } + /> + { points.length ? ( + <PanelBody title={ __( 'Markers' ) } initialOpen={ false }> + <Locations + points={ points } + onChange={ value => { + setAttributes( { points: value } ); + } } + /> + </PanelBody> + ) : null } + <PanelBody title={ __( 'Mapbox Access Token' ) } initialOpen={ false }> + <TextControl + label={ __( 'Mapbox Access Token' ) } + value={ apiKeyControl } + onChange={ value => this.setState( { apiKeyControl: value } ) } + /> + <ButtonGroup> + <Button type="button" onClick={ this.updateAPIKey } isDefault> + { __( 'Update Token' ) } + </Button> + <Button type="button" onClick={ this.removeAPIKey } isDefault> + { __( 'Remove Token' ) } + </Button> + </ButtonGroup> + </PanelBody> + </InspectorControls> + </Fragment> + ); + const placholderAPIStateLoading = ( + <Placeholder icon={ settings.icon }> + <Spinner /> + </Placeholder> + ); + const placeholderAPIStateFailure = ( + <Placeholder icon={ settings.icon } label={ __( 'Map' ) } notices={ notices }> + <Fragment> + <div className="components-placeholder__instructions"> + { __( 'To use the map block, you need an Access Token.' ) } + <br /> + <ExternalLink href="https://www.mapbox.com"> + { __( 'Create an account or log in to Mapbox.' ) } + </ExternalLink> + <br /> + { __( + 'Locate and copy the default access token. Then, paste it into the field below.' + ) } + </div> + <TextControl + className="wp-block-jetpack-map-components-text-control-api-key" + disabled={ apiRequestOutstanding } + placeholder={ __( 'Paste Token Here' ) } + value={ apiKeyControl } + onChange={ this.updateAPIKeyControl } + /> + <Button + className="wp-block-jetpack-map-components-text-control-api-key-submit" + isLarge + disabled={ apiRequestOutstanding || ! apiKeyControl || apiKeyControl.length < 1 } + onClick={ this.updateAPIKey } + > + { __( 'Set Token' ) } + </Button> + </Fragment> + </Placeholder> + ); + const placeholderAPIStateSuccess = ( + <Fragment> + { inspectorControls } + <div className={ className }> + <Map + ref={ this.mapRef } + mapStyle={ mapStyle } + mapDetails={ mapDetails } + points={ points } + zoom={ zoom } + mapCenter={ mapCenter } + markerColor={ markerColor } + onSetZoom={ value => { + setAttributes( { zoom: value } ); + } } + admin={ true } + apiKey={ apiKey } + onSetPoints={ value => setAttributes( { points: value } ) } + onMapLoaded={ () => this.setState( { addPointVisibility: true } ) } + onMarkerClick={ () => this.setState( { addPointVisibility: false } ) } + onError={ this.onError } + > + { addPointVisibility && ( + <AddPoint + onAddPoint={ this.addPoint } + onClose={ () => this.setState( { addPointVisibility: false } ) } + apiKey={ apiKey } + onError={ this.onError } + tagName="AddPoint" + /> + ) } + </Map> + </div> + </Fragment> + ); + return ( + <Fragment> + { noticeUI } + { apiState === API_STATE_LOADING && placholderAPIStateLoading } + { apiState === API_STATE_FAILURE && placeholderAPIStateFailure } + { apiState === API_STATE_SUCCESS && placeholderAPIStateSuccess } + </Fragment> + ); + } +} + +export default withNotices( MapEdit ); diff --git a/extensions/blocks/map/editor.js b/extensions/blocks/map/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/map/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/map/editor.scss b/extensions/blocks/map/editor.scss new file mode 100644 index 0000000000000..ab66d12b53279 --- /dev/null +++ b/extensions/blocks/map/editor.scss @@ -0,0 +1,28 @@ + +.wp-block-jetpack-map__delete-btn { + padding: 0; + svg { + margin-right: 0.4em; + } +} +.wp-block-jetpack-map-components-text-control-api-key { + margin-right: 4px; + &.components-base-control .components-base-control__field { + margin-bottom: 0; + } +} +.wp-block-jetpack-map-components-text-control-api-key-submit.is-large { + height: 31px; +} +.wp-block-jetpack-map-components-text-control-api-key-submit:disabled { + opacity: 1; +} +.wp-block[data-type='jetpack/map'] { + .components-placeholder__label { + svg { + fill: currentColor; + margin-right: 6px; + margin-right: 1ch; + } + } +} diff --git a/extensions/blocks/map/index.js b/extensions/blocks/map/index.js new file mode 100644 index 0000000000000..2e66caaebd597 --- /dev/null +++ b/extensions/blocks/map/index.js @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import { settings as mapSettings } from './settings.js'; +import edit from './edit'; +import save from './save'; +import './style.scss'; +import './editor.scss'; + +export const { name } = mapSettings; + +export const settings = { + title: mapSettings.title, + icon: mapSettings.icon, + category: mapSettings.category, + keywords: mapSettings.keywords, + description: mapSettings.description, + attributes: mapSettings.attributes, + supports: mapSettings.supports, + getEditWrapperProps( attributes ) { + const { align } = attributes; + if ( -1 !== mapSettings.validAlignments.indexOf( align ) ) { + return { 'data-align': align }; + } + }, + edit, + save, +}; diff --git a/extensions/blocks/map/info-window/index.js b/extensions/blocks/map/info-window/index.js new file mode 100644 index 0000000000000..f469efad91c8f --- /dev/null +++ b/extensions/blocks/map/info-window/index.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ + +import { Component, createPortal } from '@wordpress/element'; + +export class InfoWindow extends Component { + componentDidMount() { + const { mapboxgl } = this.props; + this.el = document.createElement( 'DIV' ); + this.infowindow = new mapboxgl.Popup( { + closeButton: true, + closeOnClick: false, + offset: { + left: [ 0, 0 ], + top: [ 0, 5 ], + right: [ 0, 0 ], + bottom: [ 0, -40 ], + }, + } ); + this.infowindow.setDOMContent( this.el ); + this.infowindow.on( 'close', this.closeClick ); + } + componentDidUpdate( prevProps ) { + if ( this.props.activeMarker !== prevProps.activeMarker ) { + this.props.activeMarker ? this.openWindow() : this.closeWindow(); + } + } + render() { + // Use React portal to render components directly into the Mapbox info window. + return this.el ? createPortal( this.props.children, this.el ) : null; + } + closeClick = () => { + this.props.unsetActiveMarker(); + }; + openWindow() { + const { map, activeMarker } = this.props; + this.infowindow.setLngLat( activeMarker.getPoint() ).addTo( map ); + } + closeWindow() { + this.infowindow.remove(); + } +} + +InfoWindow.defaultProps = { + unsetActiveMarker: () => {}, + activeMarker: null, + map: null, + mapboxgl: null, +}; + +export default InfoWindow; diff --git a/extensions/blocks/map/location-search/index.js b/extensions/blocks/map/location-search/index.js new file mode 100644 index 0000000000000..43c2d9edb0d2e --- /dev/null +++ b/extensions/blocks/map/location-search/index.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import { BaseControl, TextControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import Lookup from '../lookup'; +import { __ } from '../../../utils/i18n'; + +const placeholderText = __( 'Add a marker…' ); + +export class LocationSearch extends Component { + constructor() { + super( ...arguments ); + + this.textRef = createRef(); + this.containerRef = createRef(); + this.state = { + isEmpty: true, + }; + this.autocompleter = { + name: 'placeSearch', + options: this.search, + isDebounced: true, + getOptionLabel: option => <span>{ option.place_name }</span>, + getOptionKeywords: option => [ option.place_name ], + getOptionCompletion: this.getOptionCompletion, + }; + } + componentDidMount() { + setTimeout( () => { + this.containerRef.current.querySelector( 'input' ).focus(); + }, 50 ); + } + getOptionCompletion = option => { + const { value } = option; + const point = { + placeTitle: value.text, + title: value.text, + caption: value.place_name, + id: value.id, + coordinates: { + longitude: value.geometry.coordinates[ 0 ], + latitude: value.geometry.coordinates[ 1 ], + }, + }; + this.props.onAddPoint( point ); + return value.text; + }; + + search = value => { + const { apiKey, onError } = this.props; + const url = + 'https://api.mapbox.com/geocoding/v5/mapbox.places/' + + encodeURI( value ) + + '.json?access_token=' + + apiKey; + return new Promise( function( resolve, reject ) { + const xhr = new XMLHttpRequest(); + xhr.open( 'GET', url ); + xhr.onload = function() { + if ( xhr.status === 200 ) { + const res = JSON.parse( xhr.responseText ); + resolve( res.features ); + } else { + const res = JSON.parse( xhr.responseText ); + onError( res.statusText, res.responseJSON.message ); + reject( new Error( 'Mapbox Places Error' ) ); + } + }; + xhr.send(); + } ); + }; + onReset = () => { + this.textRef.current.value = null; + }; + render() { + const { label } = this.props; + return ( + <div ref={ this.containerRef }> + <BaseControl label={ label } className="components-location-search"> + <Lookup completer={ this.autocompleter } onReset={ this.onReset }> + { ( { isExpanded, listBoxId, activeId, onChange, onKeyDown } ) => ( + <TextControl + placeholder={ placeholderText } + ref={ this.textRef } + onChange={ onChange } + aria-expanded={ isExpanded } + aria-owns={ listBoxId } + aria-activedescendant={ activeId } + onKeyDown={ onKeyDown } + /> + ) } + </Lookup> + </BaseControl> + </div> + ); + } +} + +LocationSearch.defaultProps = { + onError: () => {}, +}; + +export default LocationSearch; diff --git a/extensions/blocks/map/locations/index.js b/extensions/blocks/map/locations/index.js new file mode 100644 index 0000000000000..80385891a2656 --- /dev/null +++ b/extensions/blocks/map/locations/index.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { + Button, + Dashicon, + Panel, + PanelBody, + TextareaControl, + TextControl, +} from '@wordpress/components'; +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import './style.scss'; + +export class Locations extends Component { + constructor() { + super( ...arguments ); + this.state = { + selectedCell: null, + }; + } + + onDeletePoint = e => { + const index = parseInt( e.target.getAttribute( 'data-id' ) ); + const { points, onChange } = this.props; + + const newPoints = points.slice( 0 ); + newPoints.splice( index, 1 ); + onChange( newPoints ); + }; + + setMarkerField( field, value, index ) { + const { points, onChange } = this.props; + + const newPoints = points.slice( 0 ); + newPoints[ index ][ field ] = value; + onChange( newPoints ); + } + + render() { + const { points } = this.props; + const rows = points.map( ( point, index ) => ( + <PanelBody title={ point.placeTitle } key={ point.id } initialOpen={ false }> + <TextControl + label="Marker Title" + value={ point.title } + onChange={ title => this.setMarkerField( 'title', title, index ) } + /> + <TextareaControl + label="Marker Caption" + value={ point.caption } + rows="3" + onChange={ caption => this.setMarkerField( 'caption', caption, index ) } + /> + <Button + data-id={ index } + onClick={ this.onDeletePoint } + className="component__locations__delete-btn" + > + <Dashicon icon="trash" size="15" /> Delete Marker + </Button> + </PanelBody> + ) ); + return ( + <div className="component__locations"> + <Panel className="component__locations__panel">{ rows }</Panel> + </div> + ); + } +} + +Locations.defaultProps = { + points: Object.freeze( [] ), + onChange: () => {}, +}; + +export default Locations; diff --git a/extensions/blocks/map/locations/style.scss b/extensions/blocks/map/locations/style.scss new file mode 100644 index 0000000000000..73f5e8b51d2e1 --- /dev/null +++ b/extensions/blocks/map/locations/style.scss @@ -0,0 +1,27 @@ + +.component__locations__panel { + .edit-post-settings-sidebar__panel-block & { + margin-bottom: 1em; + &:empty { + display: none; + } + .components-panel__body:first-child { + border-top: none; + } + .components-panel__body, + .components-panel__body:first-child, + .components-panel__body:last-child { + max-width: 100%; + margin: 0; + } + .components-panel__body button { + padding-right: 40px; + } + } +} +.component__locations__delete-btn { + padding: 0; + svg { + margin-right: 0.4em; + } +} diff --git a/extensions/blocks/map/lookup/index.js b/extensions/blocks/map/lookup/index.js new file mode 100644 index 0000000000000..e4ded4d691fee --- /dev/null +++ b/extensions/blocks/map/lookup/index.js @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { Button, Popover, withFocusOutside, withSpokenMessages } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { debounce, map } from 'lodash'; +import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; +import { sprintf } from '@wordpress/i18n'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { __, _n } from '../../../utils/i18n'; + +function filterOptions( options = [], maxResults = 10 ) { + const filtered = []; + for ( let i = 0; i < options.length; i++ ) { + const option = options[ i ]; + + // Merge label into keywords + let { keywords = [] } = option; + if ( 'string' === typeof option.label ) { + keywords = [ ...keywords, option.label ]; + } + + filtered.push( option ); + + // Abort early if max reached + if ( filtered.length === maxResults ) { + break; + } + } + + return filtered; +} + +export class Lookup extends Component { + static getInitialState() { + return { + selectedIndex: 0, + query: undefined, + filteredOptions: [], + isOpen: false, + }; + } + + constructor() { + super( ...arguments ); + this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); + this.state = this.constructor.getInitialState(); + } + + componentWillUnmount() { + this.debouncedLoadOptions.cancel(); + } + + select = option => { + const { completer } = this.props; + const getOptionCompletion = completer.getOptionCompletion || {}; + getOptionCompletion( option ); + this.reset(); + }; + + reset = () => { + this.setState( this.constructor.getInitialState() ); + }; + + handleFocusOutside() { + this.reset(); + } + + loadOptions( completer, query ) { + const { options } = completer; + const promise = ( this.activePromise = Promise.resolve( + typeof options === 'function' ? options( query ) : options + ).then( optionsData => { + if ( promise !== this.activePromise ) { + // Another promise has become active since this one was asked to resolve, so do nothing, + // or else we might end triggering a race condition updating the state. + return; + } + const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( { + key: `${ optionIndex }`, + value: optionData, + label: completer.getOptionLabel( optionData ), + keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], + } ) ); + + const filteredOptions = filterOptions( keyedOptions ); + const selectedIndex = + filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; + this.setState( { + [ 'options' ]: keyedOptions, + filteredOptions, + selectedIndex, + isOpen: filteredOptions.length > 0, + } ); + this.announce( filteredOptions ); + } ) ); + } + + onChange = query => { + const { completer } = this.props; + const { options } = this.state; + + if ( ! query ) { + this.reset(); + return; + } + + if ( completer ) { + if ( completer.isDebounced ) { + this.debouncedLoadOptions( completer, query ); + } else { + this.loadOptions( completer, query ); + } + } + + const filteredOptions = completer ? filterOptions( options ) : []; + if ( completer ) { + this.setState( { selectedIndex: 0, filteredOptions, query } ); + } + }; + + onKeyDown = event => { + const { isOpen, selectedIndex, filteredOptions } = this.state; + if ( ! isOpen ) { + return; + } + let nextSelectedIndex; + switch ( event.keyCode ) { + case UP: + nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case DOWN: + nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case ENTER: + this.select( filteredOptions[ selectedIndex ] ); + break; + + case LEFT: + case RIGHT: + case ESCAPE: + this.reset(); + return; + + default: + return; + } + + // Any handled keycode should prevent original behavior. This relies on + // the early return in the default case. + event.preventDefault(); + event.stopPropagation(); + }; + announce( filteredOptions ) { + const { debouncedSpeak } = this.props; + if ( ! debouncedSpeak ) { + return; + } + if ( filteredOptions.length ) { + debouncedSpeak( + sprintf( + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + filteredOptions.length, + 'jetpack' + ), + filteredOptions.length + ), + 'assertive' + ); + } else { + debouncedSpeak( __( 'No results.' ), 'assertive' ); + } + } + render() { + const { onChange, onKeyDown } = this; + const { children, instanceId, completer } = this.props; + const { selectedIndex, filteredOptions } = this.state; + const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; + const { className } = completer; + const isExpanded = filteredOptions.length > 0; + const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; + const activeId = isExpanded + ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` + : null; + return ( + <div className="components-autocomplete"> + { children( { isExpanded, listBoxId, activeId, onChange, onKeyDown } ) } + { isExpanded && ( + <Popover + focusOnMount={ false } + onClose={ this.reset } + position="top center" + className="components-autocomplete__popover" + noArrow + > + <div id={ listBoxId } role="listbox" className="components-autocomplete__results"> + { map( filteredOptions, ( option, index ) => ( + <Button + key={ option.key } + id={ `components-autocomplete-item-${ instanceId }-${ option.key }` } + role="option" + aria-selected={ index === selectedIndex } + disabled={ option.isDisabled } + className={ classnames( 'components-autocomplete__result', className, { + 'is-selected': index === selectedIndex, + } ) } + onClick={ () => this.select( option ) } + > + { option.label } + </Button> + ) ) } + </div> + </Popover> + ) } + </div> + ); + } +} +export default compose( [ + withSpokenMessages, + withInstanceId, + withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside +] )( Lookup ); diff --git a/extensions/blocks/map/map-marker/index.js b/extensions/blocks/map/map-marker/index.js new file mode 100644 index 0000000000000..e8db9934a76e4 --- /dev/null +++ b/extensions/blocks/map/map-marker/index.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import './style.scss'; + +export class MapMarker extends Component { + componentDidMount() { + this.renderMarker(); + } + componentWillUnmount() { + if ( this.marker ) { + this.marker.remove(); + } + } + componentDidUpdate() { + this.renderMarker(); + } + handleClick = () => { + const { onClick } = this.props; + onClick( this ); + }; + getPoint = () => { + const { point } = this.props; + return [ point.coordinates.longitude, point.coordinates.latitude ]; + }; + renderMarker() { + const { map, point, mapboxgl, markerColor } = this.props; + const { handleClick } = this; + const mapboxPoint = [ point.coordinates.longitude, point.coordinates.latitude ]; + const el = this.marker ? this.marker.getElement() : document.createElement( 'div' ); + if ( this.marker ) { + this.marker.setLngLat( mapboxPoint ); + } else { + el.className = 'wp-block-jetpack-map-marker'; + this.marker = new mapboxgl.Marker( el ) + .setLngLat( mapboxPoint ) + .setOffset( [ 0, -19 ] ) + .addTo( map ); + + this.marker.getElement().addEventListener( 'click', handleClick ); + } + el.innerHTML = + '<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 32 38" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill-rule="evenodd"><path id="d" d="m16 38s16-11.308 16-22-7.1634-16-16-16-16 5.3076-16 16 16 22 16 22z" fill="' + + markerColor + + '" mask="url(#c)"/></g></svg>'; + } + render() { + return null; + } +} + +MapMarker.defaultProps = { + point: {}, + map: null, + markerColor: '#000000', + mapboxgl: null, + onClick: () => {}, +}; + +export default MapMarker; diff --git a/extensions/blocks/map/map-marker/style.scss b/extensions/blocks/map/map-marker/style.scss new file mode 100644 index 0000000000000..6c5a2a65a1cc8 --- /dev/null +++ b/extensions/blocks/map/map-marker/style.scss @@ -0,0 +1,6 @@ + +.wp-block-jetpack-map-marker { + width: 32px; + height: 38px; + opacity: 0.9; +} diff --git a/extensions/blocks/map/map-theme-picker/index.js b/extensions/blocks/map/map-theme-picker/index.js new file mode 100644 index 0000000000000..a4286f1f496c7 --- /dev/null +++ b/extensions/blocks/map/map-theme-picker/index.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { Button, ButtonGroup } from '@wordpress/components'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import './style.scss'; + +export class MapThemePicker extends Component { + render() { + const { options, value, onChange, label } = this.props; + const buttons = options.map( ( option, index ) => { + const classes = classnames( + 'component__map-theme-picker__button', + 'is-theme-' + option.value, + option.value === value ? 'is-selected' : '' + ); + return ( + <Button + className={ classes } + title={ option.label } + key={ index } + onClick={ () => onChange( option.value ) } + > + { option.label } + </Button> + ); + } ); + return ( + <div className="component__map-theme-picker components-base-control"> + <label className="components-base-control__label">{ label }</label> + <ButtonGroup>{ buttons }</ButtonGroup> + </div> + ); + } +} + +MapThemePicker.defaultProps = { + label: '', + options: [], + value: null, + onChange: () => {}, +}; + +export default MapThemePicker; diff --git a/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg b/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg new file mode 100644 index 0000000000000..34cc14120423d Binary files /dev/null and b/extensions/blocks/map/map-theme-picker/map-theme_black_and_white.jpg differ diff --git a/extensions/blocks/map/map-theme-picker/map-theme_default.jpg b/extensions/blocks/map/map-theme-picker/map-theme_default.jpg new file mode 100644 index 0000000000000..35505eb164be3 Binary files /dev/null and b/extensions/blocks/map/map-theme-picker/map-theme_default.jpg differ diff --git a/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg b/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg new file mode 100644 index 0000000000000..ef6ae41708ada Binary files /dev/null and b/extensions/blocks/map/map-theme-picker/map-theme_satellite.jpg differ diff --git a/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg b/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg new file mode 100644 index 0000000000000..eee1a2dae1c3d Binary files /dev/null and b/extensions/blocks/map/map-theme-picker/map-theme_terrain.jpg differ diff --git a/extensions/blocks/map/map-theme-picker/style.scss b/extensions/blocks/map/map-theme-picker/style.scss new file mode 100644 index 0000000000000..b970fa502c818 --- /dev/null +++ b/extensions/blocks/map/map-theme-picker/style.scss @@ -0,0 +1,35 @@ + +.component__map-theme-picker__button { + .edit-post-settings-sidebar__panel-block & { + border: 1px solid lightgray; + border-radius: 100%; + width: 56px; + height: 56px; + margin: 2px; + text-indent: -9999px; + background-color: lightgray; + background-position: center center; + background-repeat: no-repeat; + background-size: contain; + transform: scale( 1 ); + transition: transform 0.2s ease; + &:hover { + transform: scale( 1.1 ); + } + &.is-selected { + border-color: black; + } + &.is-theme-default { + background-image: url( './map-theme_default.jpg' ); + } + &.is-theme-black_and_white { + background-image: url( './map-theme_black_and_white.jpg' ); + } + &.is-theme-satellite { + background-image: url( './map-theme_satellite.jpg' ); + } + &.is-theme-terrain { + background-image: url( './map-theme_terrain.jpg' ); + } + } +} diff --git a/extensions/blocks/map/mapbox-map-formatter/index.js b/extensions/blocks/map/mapbox-map-formatter/index.js new file mode 100644 index 0000000000000..6ec21ad855f60 --- /dev/null +++ b/extensions/blocks/map/mapbox-map-formatter/index.js @@ -0,0 +1,22 @@ +export function mapboxMapFormatter( mapStyle, mapDetails ) { + const style_urls = { + default: { + details: 'mapbox://styles/automattic/cjolkhmez0qdd2ro82dwog1in', + no_details: 'mapbox://styles/automattic/cjolkci3905d82soef4zlmkdo', + }, + black_and_white: { + details: 'mapbox://styles/automattic/cjolkixvv0ty42spgt2k4j434', + no_details: 'mapbox://styles/automattic/cjolkgc540tvj2spgzzoq37k4', + }, + satellite: { + details: 'mapbox://styles/mapbox/satellite-streets-v10', + no_details: 'mapbox://styles/mapbox/satellite-v9', + }, + terrain: { + details: 'mapbox://styles/automattic/cjolkf8p405fh2soet2rdt96b', + no_details: 'mapbox://styles/automattic/cjolke6fz12ys2rpbpvgl12ha', + }, + }; + const style_url = style_urls[ mapStyle ][ mapDetails ? 'details' : 'no_details' ]; + return style_url; +} diff --git a/extensions/blocks/map/save.js b/extensions/blocks/map/save.js new file mode 100644 index 0000000000000..ffa82641dcd83 --- /dev/null +++ b/extensions/blocks/map/save.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ + +import { Component } from '@wordpress/element'; + +class MapSave extends Component { + render() { + const { attributes } = this.props; + const { align, mapStyle, mapDetails, points, zoom, mapCenter, markerColor } = attributes; + const pointsList = points.map( ( point, index ) => { + const { longitude, latitude } = point.coordinates; + const url = 'https://www.google.com/maps/search/?api=1&query=' + latitude + ',' + longitude; + return ( + <li key={ index }> + <a href={ url }>{ point.title }</a> + </li> + ); + } ); + const alignClassName = align ? `align${ align }` : null; + // All camelCase attribute names converted to snake_case data attributes + return ( + <div + className={ alignClassName } + data-map-style={ mapStyle } + data-map-details={ mapDetails } + data-points={ JSON.stringify( points ) } + data-zoom={ zoom } + data-map-center={ JSON.stringify( mapCenter ) } + data-marker-color={ markerColor } + > + { points.length > 0 && <ul>{ pointsList }</ul> } + </div> + ); + } +} + +export default MapSave; diff --git a/extensions/blocks/map/settings.js b/extensions/blocks/map/settings.js new file mode 100644 index 0000000000000..ce0ceaeee2ba5 --- /dev/null +++ b/extensions/blocks/map/settings.js @@ -0,0 +1,100 @@ +// Disable forbidden <svg> etc. so that frontend component does not depend on @wordpress/component +/* eslint-disable react/forbid-elements */ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; + +export const settings = { + name: 'map', + prefix: 'jetpack', + title: __( 'Map' ), + icon: ( + /* Do not use SVG components from @wordpress/component to avoid frontend bloat */ + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + role="img" + aria-hidden="true" + focusable="false" + > + <path fill="none" d="M0 0h24v24H0V0z" /> + <path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM10 5.47l4 1.4v11.66l-4-1.4V5.47zm-5 .99l3-1.01v11.7l-3 1.16V6.46zm14 11.08l-3 1.01V6.86l3-1.16v11.84z" /> + </svg> + ), + category: 'jetpack', + keywords: [ __( 'map' ), __( 'location' ) ], + description: __( 'Add an interactive map showing one or more locations.' ), + attributes: { + align: { + type: 'string', + }, + points: { + type: 'array', + default: [], + }, + mapStyle: { + type: 'string', + default: 'default', + }, + mapDetails: { + type: 'boolean', + default: true, + }, + zoom: { + type: 'integer', + default: 13, + }, + mapCenter: { + type: 'object', + default: { + longitude: -122.41941550000001, + latitude: 37.7749295, + }, + }, + markerColor: { + type: 'string', + default: 'red', + }, + }, + supports: { + html: false, + }, + mapStyleOptions: [ + { + value: 'default', + label: __( 'Basic' ), + }, + { + value: 'black_and_white', + label: __( 'Black and white' ), + }, + { + value: 'satellite', + label: __( 'Satellite' ), + }, + { + value: 'terrain', + label: __( 'Terrain' ), + }, + ], + validAlignments: [ 'center', 'wide', 'full' ], + markerIcon: ( + /* Do not use SVG components from @wordpress/component to avoid frontend bloat */ + <svg width="14" height="20" viewBox="0 0 14 20" xmlns="http://www.w3.org/2000/svg"> + <g id="Page-1" fill="none" fillRule="evenodd"> + <g id="outline-add_location-24px" transform="translate(-5 -2)"> + <polygon id="Shape" points="0 0 24 0 24 24 0 24" /> + <path + d="M12,2 C8.14,2 5,5.14 5,9 C5,14.25 12,22 12,22 C12,22 19,14.25 19,9 C19,5.14 15.86,2 12,2 Z M7,9 C7,6.24 9.24,4 12,4 C14.76,4 17,6.24 17,9 C17,11.88 14.12,16.19 12,18.88 C9.92,16.21 7,11.85 7,9 Z M13,6 L11,6 L11,8 L9,8 L9,10 L11,10 L11,12 L13,12 L13,10 L15,10 L15,8 L13,8 L13,6 Z" + id="Shape" + fill="#000" + fillRule="nonzero" + /> + </g> + </g> + </svg> + ), +}; diff --git a/extensions/blocks/map/style.scss b/extensions/blocks/map/style.scss new file mode 100644 index 0000000000000..72fc4a4abbc75 --- /dev/null +++ b/extensions/blocks/map/style.scss @@ -0,0 +1,21 @@ + +.wp-block-jetpack-map { + .wp-block-jetpack-map__gm-container { + width: 100%; + overflow: hidden; + background: lightgray; + min-height: 400px; + text-align: left; + } + .mapboxgl-popup { + h3 { + font-size: 1.3125em; + font-weight: 400; + margin-bottom: 0.5rem; + } + p { + margin-bottom: 0; + } + max-width: 300px; + } +} diff --git a/extensions/blocks/map/view.js b/extensions/blocks/map/view.js new file mode 100644 index 0000000000000..fc825d6704185 --- /dev/null +++ b/extensions/blocks/map/view.js @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import './style.scss'; +import component from './component.js'; +import { settings } from './settings.js'; +import FrontendManagement from '../../shared/frontend-management.js'; + +typeof window !== 'undefined' && + window.addEventListener( 'load', function() { + const frontendManagement = new FrontendManagement(); + // Add apiKey to attibutes so FrontendManagement knows about it. + // It is dynamically being added on the php side. + // So that it can be updated accross all the map blocks at the same time. + const apiKey = { + type: 'string', + default: '', + }; + frontendManagement.blockIterator( document, [ + { + component: component, + options: { + settings: { + ...settings, + attributes: { + ...settings.attributes, + apiKey, + }, + }, + }, + }, + ] ); + } ); diff --git a/extensions/blocks/markdown/edit.js b/extensions/blocks/markdown/edit.js new file mode 100644 index 0000000000000..da938f30527d0 --- /dev/null +++ b/extensions/blocks/markdown/edit.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { BlockControls, PlainText } from '@wordpress/editor'; +import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import MarkdownRenderer from './renderer'; +import { __ } from '../../utils/i18n'; + +/** + * Module variables + */ +const PANEL_EDITOR = 'editor'; +const PANEL_PREVIEW = 'preview'; + +class MarkdownEdit extends Component { + input = null; + + state = { + activePanel: PANEL_EDITOR, + }; + + bindInput = ref => void ( this.input = ref ); + + componentDidUpdate( prevProps ) { + if ( + prevProps.isSelected && + ! this.props.isSelected && + this.state.activePanel === PANEL_PREVIEW + ) { + this.toggleMode( PANEL_EDITOR )(); + } + if ( + ! prevProps.isSelected && + this.props.isSelected && + this.state.activePanel === PANEL_EDITOR && + this.input + ) { + this.input.focus(); + } + } + + isEmpty() { + const source = this.props.attributes.source; + return ! source || source.trim() === ''; + } + + updateSource = source => this.props.setAttributes( { source } ); + + handleKeyDown = e => { + const { attributes, removeBlock } = this.props; + const { source } = attributes; + + // Remove the block if source is empty and we're pressing the Backspace key + if ( e.keyCode === 8 && source === '' ) { + removeBlock(); + e.preventDefault(); + } + }; + + toggleMode = mode => () => this.setState( { activePanel: mode } ); + + renderToolbarButton( mode, label ) { + const { activePanel } = this.state; + + return ( + <button + className={ `components-tab-button ${ activePanel === mode ? 'is-active' : '' }` } + onClick={ this.toggleMode( mode ) } + > + <span>{ label }</span> + </button> + ); + } + + render() { + const { attributes, className, isSelected } = this.props; + const { source } = attributes; + const { activePanel } = this.state; + + if ( ! isSelected && this.isEmpty() ) { + return ( + <p className={ `${ className }__placeholder` }> + { __( 'Write your _Markdown_ **here**…' ) } + </p> + ); + } + + return ( + <div className={ className }> + <BlockControls> + <div className="components-toolbar"> + { this.renderToolbarButton( PANEL_EDITOR, __( 'Markdown' ) ) } + { this.renderToolbarButton( PANEL_PREVIEW, __( 'Preview' ) ) } + </div> + </BlockControls> + + { activePanel === PANEL_PREVIEW || ! isSelected ? ( + <MarkdownRenderer className={ `${ className }__preview` } source={ source } /> + ) : ( + <PlainText + className={ `${ className }__editor` } + onChange={ this.updateSource } + onKeyDown={ this.handleKeyDown } + aria-label={ __( 'Markdown' ) } + innerRef={ this.bindInput } + value={ source } + /> + ) } + </div> + ); + } +} + +export default compose( [ + withSelect( select => ( { + currentBlockId: select( 'core/editor' ).getSelectedBlockClientId(), + } ) ), + withDispatch( ( dispatch, { currentBlockId } ) => ( { + removeBlock: () => dispatch( 'core/editor' ).removeBlocks( currentBlockId ), + } ) ), +] )( MarkdownEdit ); diff --git a/extensions/blocks/markdown/editor.js b/extensions/blocks/markdown/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/markdown/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/markdown/editor.scss b/extensions/blocks/markdown/editor.scss new file mode 100644 index 0000000000000..66fe610e4949c --- /dev/null +++ b/extensions/blocks/markdown/editor.scss @@ -0,0 +1,148 @@ +// @TODO: Replace with Gutenberg variables +$black: #000; +$dark-gray-100: #8f98a1; +$dark-gray-800: #23282d; +$editor-html-font: Menlo, Consolas, monaco, monospace; +$light-gray-200: #f3f4f5; +$light-gray-500: #e2e4e7; +$text-editor-font-size: inherit; + +.wp-block-jetpack-markdown__placeholder { + opacity: 0.62; // See https://github.com/WordPress/gutenberg/blob/c0f87a212b0ad25c18ac5bf8c2e9b1cb780f1a14/packages/editor/src/components/rich-text/style.scss#L110 + pointer-events: none; +} + +// @TODO: Remove all these specific styles when related Gutenberg core styles become more generic +.editor-block-list__block { + .wp-block-jetpack-markdown__preview { + min-height: 1.8em; + line-height: 1.8; + + & > * { + margin-top: 32px; + margin-bottom: 32px; + } + + h1, + h2, + h3 { + line-height: 1.4; + } + + h1 { + font-size: 2.44em; + } + + h2 { + font-size: 1.95em; + } + + h3 { + font-size: 1.56em; + } + + h4 { + font-size: 1.25em; + line-height: 1.5; + } + + h5 { + font-size: 1em; + } + + h6 { + font-size: 0.8em; + } + + hr { + border: none; + border-bottom: 2px solid $dark-gray-100; + margin: 2em auto; + max-width: 100px; + } + + p { + line-height: 1.8; + } + + blockquote { + border-left: 4px solid $black; + margin-left: 0; + margin-right: 0; + padding-left: 1em; + + p { + line-height: 1.5; + margin: 1em 0; + } + } + + ul, + ol { + margin-left: 1.3em; + padding-left: 1.3em; + } + + li { + p { + margin: 0; + } + } + + code, + pre { + color: $dark-gray-800; + font-family: $editor-html-font; + } + + code { + background: $light-gray-200; + border-radius: 2px; + font-size: $text-editor-font-size; + padding: 2px; + } + + pre { + border-radius: 4px; + border: 1px solid $light-gray-500; + font-size: 14px; + padding: 0.8em 1em; + + code { + background: transparent; + padding: 0; + } + } + + table { + overflow-x: auto; + border-collapse: collapse; + width: 100%; + } + + thead, + tbody, + tfoot { + width: 100%; + min-width: 240px; + } + + td, + th { + padding: 0.5em; + border: 1px solid currentColor; + } + } +} + +.wp-block-jetpack-markdown { + .wp-block-jetpack-markdown__editor { + font-family: $editor-html-font; + font-size: 14px; + + &:focus { + border-color: transparent; + box-shadow: 0 0 0 transparent; + } + } +} diff --git a/extensions/blocks/markdown/index.js b/extensions/blocks/markdown/index.js new file mode 100644 index 0000000000000..be38d73e2d584 --- /dev/null +++ b/extensions/blocks/markdown/index.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { ExternalLink, Path, Rect, SVG } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import './editor.scss'; +import edit from './edit'; +import save from './save'; +import { __ } from '../../utils/i18n'; + +export const name = 'markdown'; + +export const settings = { + title: __( 'Markdown' ), + + description: ( + <Fragment> + <p>{ __( 'Use regular characters and punctuation to style text, links, and lists.' ) }</p> + <ExternalLink href="https://en.support.wordpress.com/markdown-quick-reference/"> + { __( 'Support reference' ) } + </ExternalLink> + </Fragment> + ), + + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 128"> + <Rect + width="198" + height="118" + x="5" + y="5" + ry="10" + stroke="currentColor" + strokeWidth="10" + fill="none" + /> + <Path d="M30 98v-68h20l20 25 20-25h20v68h-20v-39l-20 25-20-25v39zM155 98l-30-33h20v-35h20v35h20z" /> + </SVG> + ), + + category: 'jetpack', + + keywords: [ __( 'formatting' ), __( 'syntax' ), __( 'markup' ) ], + + attributes: { + //The Markdown source is saved in the block content comments delimiter + source: { type: 'string' }, + }, + + supports: { + html: false, + }, + + edit, + + save, +}; diff --git a/extensions/blocks/markdown/renderer.js b/extensions/blocks/markdown/renderer.js new file mode 100644 index 0000000000000..a77d24f24ebf5 --- /dev/null +++ b/extensions/blocks/markdown/renderer.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import MarkdownIt from 'markdown-it'; +import { RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +/** + * Module variables + */ +const markdownConverter = new MarkdownIt(); +const handleLinkClick = event => { + if ( event.target.nodeName === 'A' ) { + const hasConfirmed = window.confirm( __( 'Are you sure you wish to leave this page?' ) ); + + if ( ! hasConfirmed ) { + event.preventDefault(); + } + } +}; + +export default ( { className, source = '' } ) => ( + <RawHTML className={ className } onClick={ handleLinkClick }> + { source.length ? markdownConverter.render( source ) : '' } + </RawHTML> +); diff --git a/extensions/blocks/markdown/save.js b/extensions/blocks/markdown/save.js new file mode 100644 index 0000000000000..06d08138f7604 --- /dev/null +++ b/extensions/blocks/markdown/save.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import MarkdownRenderer from './renderer'; + +export default ( { attributes, className } ) => ( + <MarkdownRenderer className={ className } source={ attributes.source } /> +); diff --git a/extensions/blocks/publicize/connection-verify.js b/extensions/blocks/publicize/connection-verify.js new file mode 100644 index 0000000000000..92cd1f62ff583 --- /dev/null +++ b/extensions/blocks/publicize/connection-verify.js @@ -0,0 +1,115 @@ +/** + * Publicize connections verification component. + * + * Component to create Ajax request to check + * all connections. If any connection tests failed, + * a refresh link may be provided to the user. If + * no connection tests fail, this component will + * not render anything. + */ + +/** + * External dependencies + */ +import { Button, Notice } from '@wordpress/components'; +import { Component, Fragment } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +class PublicizeConnectionVerify extends Component { + componentDidMount() { + this.props.refreshConnections(); + } + + /** + * Opens up popup so user can refresh connection + * + * Displays pop up with to specified URL where user + * can refresh a specific connection. + * + * @param {object} event Event instance for onClick. + */ + refreshConnectionClick = event => { + const { href, title } = event.target; + event.preventDefault(); + // open a popup window + // when it is closed, kick off the tests again + const popupWin = window.open( href, title, '' ); + const popupTimer = window.setInterval( () => { + if ( false !== popupWin.closed ) { + window.clearInterval( popupTimer ); + this.props.refreshConnections(); + } + }, 500 ); + }; + + renderRefreshableConnections() { + const { failedConnections } = this.props; + const refreshableConnections = failedConnections.filter( connection => connection.can_refresh ); + + if ( refreshableConnections.length ) { + return ( + <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error"> + <p> + { __( + 'Before you hit Publish, please refresh the following connection(s) to make sure we can Publicize your post:' + ) } + </p> + { refreshableConnections.map( connection => ( + <Button + href={ connection.refresh_url } + isSmall + key={ connection.id } + onClick={ this.refreshConnectionClick } + title={ connection.refresh_text } + > + { connection.refresh_text } + </Button> + ) ) } + </Notice> + ); + } + + return null; + } + + renderNonRefreshableConnections() { + const { failedConnections } = this.props; + const nonRefreshableConnections = failedConnections.filter( + connection => ! connection.can_refresh + ); + + if ( nonRefreshableConnections.length ) { + return nonRefreshableConnections.map( connection => ( + <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error"> + <p>{ connection.test_message }</p> + </Notice> + ) ); + } + + return null; + } + + render() { + return ( + <Fragment> + { this.renderRefreshableConnections() } + { this.renderNonRefreshableConnections() } + </Fragment> + ); + } +} + +export default compose( [ + withSelect( select => ( { + failedConnections: select( 'jetpack/publicize' ).getFailedConnections(), + } ) ), + withDispatch( dispatch => ( { + refreshConnections: dispatch( 'jetpack/publicize' ).refreshConnectionTestResults, + } ) ), +] )( PublicizeConnectionVerify ); diff --git a/extensions/blocks/publicize/connection.js b/extensions/blocks/publicize/connection.js new file mode 100644 index 0000000000000..3bbb810c55321 --- /dev/null +++ b/extensions/blocks/publicize/connection.js @@ -0,0 +1,135 @@ +/** + * Publicize connection form component. + * + * Component to display connection label and a + * checkbox to enable/disable the connection for sharing. + */ + +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { Disabled, FormToggle, Notice, ExternalLink } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { includes } from 'lodash'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import PublicizeServiceIcon from './service-icon'; +import getSiteFragment from '../../shared/get-site-fragment'; + +class PublicizeConnection extends Component { + state = { + showGooglePlusNotice: true, + }; + + /** + * Hide notice when it's removed + */ + onRemoveGooglePlusNotice = () => { + this.setState( { + showGooglePlusNotice: false, + } ); + }; + + /** + * If this is the Google+ connection, display a notice. + * + * @param {string} serviceName Name of the connnected social network. + * @returns {object} Message warning users of Google+ shutting down. + */ + maybeDisplayGooglePlusNotice = serviceName => + 'google-plus' === serviceName && + this.state.showGooglePlusNotice && ( + <Notice status="error" onRemove={ this.onRemoveGooglePlusNotice }> + { __( + 'Google+ will shut down in April 2019. You can keep posting with your existing Google+ connection through March.' + ) } + <ExternalLink + target="_blank" + href="https://www.blog.google/technology/safety-security/expediting-changes-google-plus/" + > + { __( ' Learn more' ) }. + </ExternalLink> + </Notice> + ); + + /** + * Displays a message when a connection requires reauthentication. We used this when migrating LinkedIn API usage from v1 to v2, + * since the prevous OAuth1 tokens were incompatible with OAuth2. + * + * @returns {object|?null} Notice about reauthentication + */ + maybeDisplayLinkedInNotice = () => + this.connectionNeedsReauth() && ( + <Notice className="jetpack-publicize-notice" isDismissible={ false } status="error"> + <p> + { __( + 'Your LinkedIn connection needs to be reauthenticated ' + + 'to continue working – head to Sharing to take care of it.' + ) } + </p> + <ExternalLink href={ `https://wordpress.com/sharing/${ getSiteFragment() }` }> + { __( 'Go to Sharing settings' ) } + </ExternalLink> + </Notice> + ); + + /** + * Check whether the connection needs to be reauthenticated. + * + * @returns {boolean} True if connection must be reauthenticated. + */ + connectionNeedsReauth = () => includes( this.props.mustReauthConnections, this.props.name ); + + onConnectionChange = () => { + const { id } = this.props; + this.props.toggleConnection( id ); + }; + + connectionIsFailing() { + const { failedConnections, name } = this.props; + return failedConnections.some( connection => connection.service_name === name ); + } + + render() { + const { disabled, enabled, id, label, name } = this.props; + const fieldId = 'connection-' + name + '-' + id; + // Genericon names are dash separated + const serviceName = name.replace( '_', '-' ); + + let toggle = ( + <FormToggle + id={ fieldId } + className="jetpack-publicize-connection-toggle" + checked={ enabled } + onChange={ this.onConnectionChange } + /> + ); + + if ( disabled || this.connectionIsFailing() || this.connectionNeedsReauth() ) { + toggle = <Disabled>{ toggle }</Disabled>; + } + + return ( + <li> + { this.maybeDisplayGooglePlusNotice( serviceName ) } + { this.maybeDisplayLinkedInNotice() } + <div className="publicize-jetpack-connection-container"> + <label htmlFor={ fieldId } className="jetpack-publicize-connection-label"> + <PublicizeServiceIcon serviceName={ serviceName } /> + <span className="jetpack-publicize-connection-label-copy">{ label }</span> + </label> + { toggle } + </div> + </li> + ); + } +} + +export default withSelect( select => ( { + failedConnections: select( 'jetpack/publicize' ).getFailedConnections(), + mustReauthConnections: select( 'jetpack/publicize' ).getMustReauthConnections(), +} ) )( PublicizeConnection ); diff --git a/extensions/blocks/publicize/editor.js b/extensions/blocks/publicize/editor.js new file mode 100644 index 0000000000000..e2811b0c0d125 --- /dev/null +++ b/extensions/blocks/publicize/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import { name, settings } from '.'; +import registerJetpackPlugin from '../../utils/register-jetpack-plugin'; + +registerJetpackPlugin( name, settings ); diff --git a/extensions/blocks/publicize/editor.scss b/extensions/blocks/publicize/editor.scss new file mode 100644 index 0000000000000..ba40bef74b909 --- /dev/null +++ b/extensions/blocks/publicize/editor.scss @@ -0,0 +1,105 @@ + +// @TODO: Replace with Gutenberg variables +$dark-gray-500: #555d66; + +.jetpack-publicize-message-box { + background-color: #edeff0; + border-radius: 4px; +} + +.jetpack-publicize-message-box textarea { + width: 100%; +} + +.jetpack-publicize-character-count { + padding-bottom: 5px; + padding-left: 5px; +} + +.jetpack-publicize__connections-list { + list-style-type: none; + margin: 13px 0; +} + +.publicize-jetpack-connection-container { + display: flex; +} + +.jetpack-publicize-gutenberg-social-icon { + fill: $dark-gray-500; + margin-right: 5px; + + &.is-facebook { + fill: var( --color-facebook ); + } + &.is-twitter { + fill: var( --color-twitter ); + } + &.is-linkedin { + fill: var( --color-linkedin ); + } + &.is-tumblr { + fill: var( --color-tumblr ); + } + &.is-google-plus { + fill: var( --color-gplus ); + } +} + +.jetpack-publicize-connection-label { + flex: 1; + margin-right: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .jetpack-publicize-gutenberg-social-icon, + .jetpack-publicize-connection-label-copy { + display: inline-block; + vertical-align: middle; + } +} + +.jetpack-publicize-connection-toggle { + margin-top: 3px; +} + +.jetpack-publicize-notice { + &.components-notice { + margin-left: 0; + margin-right: 0; + margin-bottom: 13px; + } + + .components-button + .components-button { + margin-top: 5px; + } +} + +.jetpack-publicize-message-note { + display: inline-block; + margin-bottom: 4px; + margin-top: 13px; +} + +.jetpack-publicize-add-connection-wrapper { + margin: 15px 0; +} + +.jetpack-publicize-add-connection-container { + display: flex; + + a { + cursor: pointer; + } + + span { + vertical-align: middle; + } +} + +.jetpack-publicize__connections-list { + .components-notice { + margin: 5px 0 10px; + } +} diff --git a/extensions/blocks/publicize/form-unwrapped.js b/extensions/blocks/publicize/form-unwrapped.js new file mode 100644 index 0000000000000..d9640bab79102 --- /dev/null +++ b/extensions/blocks/publicize/form-unwrapped.js @@ -0,0 +1,113 @@ +/** + * Publicize sharing form component. + * + * Displays text area and connection list to allow user + * to select connections to share to and write a custom + * sharing message. + */ + +/** + * External dependencies + */ +import classnames from 'classnames'; +import { sprintf } from '@wordpress/i18n'; +import { Component, Fragment } from '@wordpress/element'; +import { uniqueId } from 'lodash'; + +/** + * Internal dependencies + */ +import PublicizeConnection from './connection'; +import PublicizeSettingsButton from './settings-button'; +import { __, _n } from '../../utils/i18n'; + +export const MAXIMUM_MESSAGE_LENGTH = 256; + +class PublicizeFormUnwrapped extends Component { + state = { + hasEditedShareMessage: false, + }; + + fieldId = uniqueId( 'jetpack-publicize-message-field-' ); + + /** + * Check to see if form should be disabled. + * + * Checks full connection list to determine if all are disabled. + * If they all are, it returns true to disable whole form. + * + * @return {boolean} True if whole form should be disabled. + */ + isDisabled() { + return this.props.connections.every( connection => ! connection.toggleable ); + } + + getShareMessage() { + const { shareMessage, defaultShareMessage } = this.props; + return ! this.state.hasEditedShareMessage && shareMessage === '' + ? defaultShareMessage + : shareMessage; + } + + onMessageChange = event => { + const { messageChange } = this.props; + this.setState( { hasEditedShareMessage: true } ); + messageChange( event ); + }; + + render() { + const { connections, toggleConnection, refreshCallback } = this.props; + const shareMessage = this.getShareMessage(); + const charactersRemaining = MAXIMUM_MESSAGE_LENGTH - shareMessage.length; + const characterCountClass = classnames( 'jetpack-publicize-character-count', { + 'wpas-twitter-length-limit': charactersRemaining <= 0, + } ); + + return ( + <div id="publicize-form"> + <ul className="jetpack-publicize__connections-list"> + { connections.map( ( { display_name, enabled, id, service_name, toggleable } ) => ( + <PublicizeConnection + disabled={ ! toggleable } + enabled={ enabled } + key={ id } + id={ id } + label={ display_name } + name={ service_name } + toggleConnection={ toggleConnection } + /> + ) ) } + </ul> + <PublicizeSettingsButton refreshCallback={ refreshCallback } /> + { connections.some( connection => connection.enabled ) && ( + <Fragment> + <label className="jetpack-publicize-message-note" htmlFor={ this.fieldId }> + { __( 'Customize your message' ) } + </label> + <div className="jetpack-publicize-message-box"> + <textarea + id={ this.fieldId } + value={ shareMessage } + onChange={ this.onMessageChange } + disabled={ this.isDisabled() } + maxLength={ MAXIMUM_MESSAGE_LENGTH } + placeholder={ __( + "Write a message for your audience here. If you leave this blank, we'll use the post title as the message." + ) } + rows={ 4 } + /> + <div className={ characterCountClass }> + { sprintf( + _n( '%d character remaining', '%d characters remaining', charactersRemaining ), + charactersRemaining + ) } + </div> + </div> + </Fragment> + ) } + </div> + ); + } +} + +export default PublicizeFormUnwrapped; diff --git a/extensions/blocks/publicize/form.js b/extensions/blocks/publicize/form.js new file mode 100644 index 0000000000000..7785d6d8b685d --- /dev/null +++ b/extensions/blocks/publicize/form.js @@ -0,0 +1,72 @@ +/** + * Higher Order Publicize sharing form composition. + * + * Uses Gutenberg data API to dispatch publicize form data to + * editor post data in format to match 'publicize' field schema. + */ + +/** + * External dependencies + */ +import get from 'lodash/get'; +import { compose } from '@wordpress/compose'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import PublicizeFormUnwrapped, { MAXIMUM_MESSAGE_LENGTH } from './form-unwrapped'; + +const PublicizeForm = compose( [ + withSelect( select => { + const meta = select( 'core/editor' ).getEditedPostAttribute( 'meta' ); + const postTitle = select( 'core/editor' ).getEditedPostAttribute( 'title' ); + const message = get( meta, [ 'jetpack_publicize_message' ], '' ); + + return { + connections: select( 'core/editor' ).getEditedPostAttribute( + 'jetpack_publicize_connections' + ), + defaultShareMessage: postTitle.substr( 0, MAXIMUM_MESSAGE_LENGTH ), + shareMessage: message.substr( 0, MAXIMUM_MESSAGE_LENGTH ), + }; + } ), + withDispatch( ( dispatch, { connections } ) => ( { + /** + * Toggle connection enable/disable state based on checkbox. + * + * Saves enable/disable value to connections property in editor + * in field 'jetpack_publicize_connections'. + * + * @param {number} id ID of the connection being enabled/disabled + */ + toggleConnection( id ) { + const newConnections = connections.map( connection => ( { + ...connection, + enabled: connection.id === id ? ! connection.enabled : connection.enabled, + } ) ); + + dispatch( 'core/editor' ).editPost( { + jetpack_publicize_connections: newConnections, + } ); + }, + + /** + * Handler for when sharing message is edited. + * + * Saves edited message to state and to the editor + * in field 'jetpack_publicize_message'. + * + * @param {object} event Change event data from textarea element. + */ + messageChange( event ) { + dispatch( 'core/editor' ).editPost( { + meta: { + jetpack_publicize_message: event.target.value, + }, + } ); + }, + } ) ), +] )( PublicizeFormUnwrapped ); + +export default PublicizeForm; diff --git a/extensions/blocks/publicize/index.js b/extensions/blocks/publicize/index.js new file mode 100644 index 0000000000000..8f29e933cf42b --- /dev/null +++ b/extensions/blocks/publicize/index.js @@ -0,0 +1,50 @@ +/** + * Top-level Publicize plugin for Gutenberg editor. + * + * Hooks into Gutenberg's PluginPrePublishPanel + * to display Jetpack's Publicize UI in the pre-publish flow. + * + * It also hooks into our dedicated Jetpack plugin sidebar and + * displays the Publicize UI there. + */ + +/** + * External dependencies + */ +import { PanelBody } from '@wordpress/components'; +import { PluginPrePublishPanel } from '@wordpress/edit-post'; +import { PostTypeSupportCheck } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import './editor.scss'; +import './store'; +import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar'; +import PublicizePanel from './panel'; +import { __ } from '../../utils/i18n'; + +export const name = 'publicize'; + +export const settings = { + render: () => ( + <PostTypeSupportCheck supportKeys="publicize"> + <JetpackPluginSidebar> + <PanelBody title={ __( 'Share this post' ) }> + <PublicizePanel /> + </PanelBody> + </JetpackPluginSidebar> + <PluginPrePublishPanel + initialOpen + id="publicize-title" + title={ + <span id="publicize-defaults" key="publicize-title-span"> + { __( 'Share this post' ) } + </span> + } + > + <PublicizePanel /> + </PluginPrePublishPanel> + </PostTypeSupportCheck> + ), +}; diff --git a/extensions/blocks/publicize/panel.js b/extensions/blocks/publicize/panel.js new file mode 100644 index 0000000000000..55cb3989599f0 --- /dev/null +++ b/extensions/blocks/publicize/panel.js @@ -0,0 +1,49 @@ +/** + * Publicize sharing panel component. + * + * Displays Publicize notifications if no + * services are connected or displays form if + * services are connected. + */ + +/** + * External dependencies + */ +import { compose } from '@wordpress/compose'; +import { Fragment } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import PublicizeConnectionVerify from './connection-verify'; +import PublicizeForm from './form'; +import PublicizeSettingsButton from './settings-button'; +import { __ } from '../../utils/i18n'; + +const PublicizePanel = ( { connections, refreshConnections } ) => ( + <Fragment> + { connections && connections.some( connection => connection.enabled ) && ( + <PublicizeConnectionVerify /> + ) } + <div>{ __( "Connect and select the accounts where you'd like to share your post." ) }</div> + { connections && connections.length > 0 && ( + <PublicizeForm refreshCallback={ refreshConnections } /> + ) } + { connections && 0 === connections.length && ( + <PublicizeSettingsButton + className="jetpack-publicize-add-connection-wrapper" + refreshCallback={ refreshConnections } + /> + ) } + </Fragment> +); + +export default compose( [ + withSelect( select => ( { + connections: select( 'core/editor' ).getEditedPostAttribute( 'jetpack_publicize_connections' ), + } ) ), + withDispatch( dispatch => ( { + refreshConnections: dispatch( 'core/editor' ).refreshPost, + } ) ), +] )( PublicizePanel ); diff --git a/extensions/blocks/publicize/service-icon.js b/extensions/blocks/publicize/service-icon.js new file mode 100644 index 0000000000000..ff15c7cacd711 --- /dev/null +++ b/extensions/blocks/publicize/service-icon.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { G, Icon, Path, Rect, SVG } from '@wordpress/components'; + +/** + * Module variables + */ +// @TODO: Import those from https://github.com/Automattic/social-logos when that's possible. +// Currently we can't directly import icons from there, because all icons are bundled in a single file. +// This means that to import an icon from there, we'll need to add the entire bundle with all icons to our build. +// In the future we'd want to export each icon in that repo separately, and then import them separately here. +const FacebookIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M20.007 3H3.993C3.445 3 3 3.445 3 3.993v16.013c0 .55.445.994.993.994h8.62v-6.97H10.27V11.31h2.346V9.31c0-2.325 1.42-3.59 3.494-3.59.993 0 1.847.073 2.096.106v2.43h-1.438c-1.128 0-1.346.537-1.346 1.324v1.734h2.69l-.35 2.717h-2.34V21h4.587c.548 0 .993-.445.993-.993V3.993c0-.548-.445-.993-.993-.993z" /> + </G> + </SVG> +); +const TwitterIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M22.23 5.924c-.736.326-1.527.547-2.357.646.847-.508 1.498-1.312 1.804-2.27-.793.47-1.67.812-2.606.996C18.325 4.498 17.258 4 16.078 4c-2.266 0-4.103 1.837-4.103 4.103 0 .322.036.635.106.935-3.41-.17-6.433-1.804-8.457-4.287-.353.607-.556 1.312-.556 2.064 0 1.424.724 2.68 1.825 3.415-.673-.022-1.305-.207-1.86-.514v.052c0 1.988 1.415 3.647 3.293 4.023-.344.095-.707.145-1.08.145-.265 0-.522-.026-.773-.074.522 1.63 2.038 2.817 3.833 2.85-1.404 1.1-3.174 1.757-5.096 1.757-.332 0-.66-.02-.98-.057 1.816 1.164 3.973 1.843 6.29 1.843 7.547 0 11.675-6.252 11.675-11.675 0-.178-.004-.355-.012-.53.802-.578 1.497-1.3 2.047-2.124z" /> + </G> + </SVG> +); +const LinkedinIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M19.7 3H4.3C3.582 3 3 3.582 3 4.3v15.4c0 .718.582 1.3 1.3 1.3h15.4c.718 0 1.3-.582 1.3-1.3V4.3c0-.718-.582-1.3-1.3-1.3zM8.34 18.338H5.666v-8.59H8.34v8.59zM7.003 8.574c-.857 0-1.55-.694-1.55-1.548 0-.855.692-1.548 1.55-1.548.854 0 1.547.694 1.547 1.548 0 .855-.692 1.548-1.546 1.548zm11.335 9.764h-2.67V14.16c0-.995-.017-2.277-1.387-2.277-1.39 0-1.6 1.086-1.6 2.206v4.248h-2.668v-8.59h2.56v1.174h.036c.357-.675 1.228-1.387 2.527-1.387 2.703 0 3.203 1.78 3.203 4.092v4.71z" /> + </G> + </SVG> +); +const TumblrIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M19 3H5c-1.105 0-2 .895-2 2v14c0 1.105.895 2 2 2h14c1.105 0 2-.895 2-2V5c0-1.105-.895-2-2-2zm-5.57 14.265c-2.445.042-3.37-1.742-3.37-2.998V10.6H8.922V9.15c1.703-.615 2.113-2.15 2.21-3.026.006-.06.053-.084.08-.084h1.645V8.9h2.246v1.7H12.85v3.495c.008.476.182 1.13 1.08 1.107.3-.008.698-.094.907-.194l.54 1.6c-.205.297-1.12.642-1.946.657z" /> + </G> + </SVG> +); +const GooglePlusIcon = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Rect x="0" fill="none" width="24" height="24" /> + <G> + <Path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1.92 14.05c-2.235 0-4.05-1.814-4.05-4.05s1.815-4.05 4.05-4.05c1.095 0 2.01.4 2.71 1.057l-1.15 1.118c-.292-.275-.802-.6-1.56-.6-1.34 0-2.433 1.115-2.433 2.48s1.094 2.48 2.434 2.48c1.552 0 2.123-1.074 2.228-1.71h-2.232v-1.51h3.79c.058.255.102.494.102.83 0 2.312-1.55 3.956-3.887 3.956zm8.92-3.3h-1.25V14h-1.5v-1.25H15v-1.5h1.25V10h1.5v1.25H19v1.5z" /> + </G> + </SVG> +); + +export default ( { serviceName } ) => { + const defaultProps = { + className: `jetpack-publicize-gutenberg-social-icon is-${ serviceName }`, + size: 24, + }; + + switch ( serviceName ) { + case 'facebook': + return <Icon icon={ FacebookIcon } { ...defaultProps } />; + case 'twitter': + return <Icon icon={ TwitterIcon } { ...defaultProps } />; + case 'linkedin': + return <Icon icon={ LinkedinIcon } { ...defaultProps } />; + case 'tumblr': + return <Icon icon={ TumblrIcon } { ...defaultProps } />; + case 'google-plus': + return <Icon icon={ GooglePlusIcon } { ...defaultProps } />; + } + + return null; +}; diff --git a/extensions/blocks/publicize/settings-button.js b/extensions/blocks/publicize/settings-button.js new file mode 100644 index 0000000000000..7f97d457c00d6 --- /dev/null +++ b/extensions/blocks/publicize/settings-button.js @@ -0,0 +1,71 @@ +/** + * Publicize settings button component. + * + * Component which allows user to click to open settings + * in a new window/tab. If window/tab is closed, then + * connections will be automatically refreshed. + */ + +/** + * External dependencies + */ +import classnames from 'classnames'; +import { Component } from '@wordpress/element'; +import { ExternalLink } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import getSiteFragment from '../../shared/get-site-fragment'; + +class PublicizeSettingsButton extends Component { + getButtonLink() { + const siteFragment = getSiteFragment(); + + // If running in WP.com wp-admin or in Calypso, we redirect to Calypso sharing settings. + if ( siteFragment ) { + return `https://wordpress.com/sharing/${ siteFragment }`; + } + + // If running in WordPress.org wp-admin we redirect to Sharing settings in wp-admin. + return 'options-general.php?page=sharing&publicize_popup=true'; + } + + /** + * Opens up popup so user can view/modify connections + * + * @param {object} event Event instance for onClick. + */ + settingsClick = event => { + const href = this.getButtonLink(); + const { refreshCallback } = this.props; + event.preventDefault(); + /** + * Open a popup window, and + * when it is closed, refresh connections + */ + const popupWin = window.open( href, '', '' ); + const popupTimer = window.setInterval( () => { + if ( false !== popupWin.closed ) { + window.clearInterval( popupTimer ); + refreshCallback(); + } + }, 500 ); + }; + + render() { + const className = classnames( + 'jetpack-publicize-add-connection-container', + this.props.className + ); + + return ( + <div className={ className }> + <ExternalLink onClick={ this.settingsClick }>{ __( 'Connect an account' ) }</ExternalLink> + </div> + ); + } +} + +export default PublicizeSettingsButton; diff --git a/extensions/blocks/publicize/store/actions.js b/extensions/blocks/publicize/store/actions.js new file mode 100644 index 0000000000000..e5b7169428a85 --- /dev/null +++ b/extensions/blocks/publicize/store/actions.js @@ -0,0 +1,41 @@ +/** + * Returns an action object used in signalling that + * we're setting the Publicize connection test results. + * + * @param {Array} results Connection test results. + * + * @return {Object} Action object. + */ +export function setConnectionTestResults( results ) { + return { + type: 'SET_CONNECTION_TEST_RESULTS', + results, + }; +} + +/** + * Returns an action object used in signalling that + * we're refreshing the Publicize connection test results. + * + * @return {Object} Action object. + */ +export function refreshConnectionTestResults() { + return { + type: 'REFRESH_CONNECTION_TEST_RESULTS', + }; +} + +/** + * Returns an action object used in signalling that + * we're initiating a fetch request to the REST API. + * + * @param {String} path API endpoint path. + * + * @return {Object} Action object. + */ +export function fetchFromAPI( path ) { + return { + type: 'FETCH_FROM_API', + path, + }; +} diff --git a/extensions/blocks/publicize/store/controls.js b/extensions/blocks/publicize/store/controls.js new file mode 100644 index 0000000000000..afe6eccdf59e4 --- /dev/null +++ b/extensions/blocks/publicize/store/controls.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Trigger an API Fetch request. + * + * @param {Object} action Action Object. + * + * @return {Promise} Fetch request promise. + */ +const fetchFromApi = ( { path } ) => { + return apiFetch( { path } ); +}; + +export default { + FETCH_FROM_API: fetchFromApi, +}; diff --git a/extensions/blocks/publicize/store/effects.js b/extensions/blocks/publicize/store/effects.js new file mode 100644 index 0000000000000..594c8b72a6d09 --- /dev/null +++ b/extensions/blocks/publicize/store/effects.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { setConnectionTestResults } from './actions'; + +/** + * Effect handler which will refresh the connection test results. + * + * @param {Object} action Action which had initiated the effect handler. + * @param {Object} store Store instance. + * + * @return {Object} Refresh connection test results action. + */ +export async function refreshConnectionTestResults( action, store ) { + const { dispatch } = store; + + try { + const results = await apiFetch( { path: '/wpcom/v2/publicize/connection-test-results' } ); + return dispatch( setConnectionTestResults( results ) ); + } catch ( error ) { + // Refreshing connections failed + } +} + +export default { + REFRESH_CONNECTION_TEST_RESULTS: refreshConnectionTestResults, +}; diff --git a/extensions/blocks/publicize/store/index.js b/extensions/blocks/publicize/store/index.js new file mode 100644 index 0000000000000..337167bc97caf --- /dev/null +++ b/extensions/blocks/publicize/store/index.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import * as selectors from './selectors'; +import applyMiddlewares from './middlewares'; +import controls from './controls'; +import reducer from './reducer'; + +const store = registerStore( 'jetpack/publicize', { + actions, + controls, + reducer, + selectors, +} ); + +applyMiddlewares( store ); + +export default store; diff --git a/extensions/blocks/publicize/store/middlewares.js b/extensions/blocks/publicize/store/middlewares.js new file mode 100644 index 0000000000000..1403b8084f1dc --- /dev/null +++ b/extensions/blocks/publicize/store/middlewares.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import refx from 'refx'; +import { flowRight } from 'lodash'; + +/** + * Internal dependencies + */ +import effects from './effects'; + +/** + * Applies the custom middlewares used specifically in the Publicize extension. + * + * @param {Object} store Store Object. + * + * @return {Object} Update Store Object. + */ +export default function applyMiddlewares( store ) { + const middlewares = [ refx( effects ) ]; + + let enhancedDispatch = () => { + throw new Error( + 'Dispatching while constructing your middleware is not allowed. ' + + 'Other middleware would not be applied to this dispatch.' + ); + }; + let chain = []; + + const middlewareAPI = { + getState: store.getState, + dispatch: ( ...args ) => enhancedDispatch( ...args ), + }; + chain = middlewares.map( middleware => middleware( middlewareAPI ) ); + enhancedDispatch = flowRight( ...chain )( store.dispatch ); + + store.dispatch = enhancedDispatch; + + return store; +} diff --git a/extensions/blocks/publicize/store/reducer.js b/extensions/blocks/publicize/store/reducer.js new file mode 100644 index 0000000000000..80af0701001a3 --- /dev/null +++ b/extensions/blocks/publicize/store/reducer.js @@ -0,0 +1,18 @@ +/** + * Reducer managing Publicize connection test results. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export default function( state = [], action ) { + switch ( action.type ) { + case 'SET_CONNECTION_TEST_RESULTS': + return action.results; + case 'REFRESH_CONNECTION_TEST_RESULTS': + return []; + } + + return state; +} diff --git a/extensions/blocks/publicize/store/selectors.js b/extensions/blocks/publicize/store/selectors.js new file mode 100644 index 0000000000000..db86a4fec0d5c --- /dev/null +++ b/extensions/blocks/publicize/store/selectors.js @@ -0,0 +1,24 @@ +/** + * Returns the failed Publicize connections. + * + * @param {Object} state State object. + * + * @return {Array} List of connections. + */ +export function getFailedConnections( state ) { + return state.filter( connection => false === connection.test_success ); +} + +/** + * Returns a list of Publicize connection service names that require reauthentication from users. + * iFor example, when LinkedIn switched its API from v1 to v2. + * + * @param {Object} state State object. + * + * @return {Array} List of service names that need reauthentication. + */ +export function getMustReauthConnections( state ) { + return state + .filter( connection => 'must_reauth' === connection.test_success ) + .map( connection => connection.service_name ); +} diff --git a/extensions/blocks/related-posts/edit.js b/extensions/blocks/related-posts/edit.js new file mode 100644 index 0000000000000..f6816e822e909 --- /dev/null +++ b/extensions/blocks/related-posts/edit.js @@ -0,0 +1,253 @@ +/** + * External dependencies + */ +import { BlockControls, InspectorControls } from '@wordpress/editor'; +import { PanelBody, RangeControl, ToggleControl, Toolbar, Path, SVG } from '@wordpress/components'; +import { Component, Fragment } from '@wordpress/element'; +import { get } from 'lodash'; +import { withSelect } from '@wordpress/data'; +import { compose, withInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +export const MAX_POSTS_TO_SHOW = 6; + +function PlaceholderPostEdit( props ) { + return ( + <div + className="jp-related-posts-i2__post" + id={ props.id } + aria-labelledby={ props.id + '-heading' } + > + <strong id={ props.id + '-heading' } className="jp-related-posts-i2__post-link"> + { __( "Preview unavailable: you haven't published enough posts with similar content." ) } + </strong> + { props.displayThumbnails && ( + <figure + className="jp-related-posts-i2__post-image-placeholder" + aria-label={ __( 'Placeholder image' ) } + > + <SVG + className="jp-related-posts-i2__post-image-placeholder-square" + xmlns="http://www.w3.org/2000/svg" + width="100%" + height="100%" + viewBox="0 0 350 200" + > + <title>{ __( 'Grey square' ) }</title> + <Path d="M0 0h350v200H0z" fill="#8B8B96" fill-opacity=".1" /> + </SVG> + <SVG + className="jp-related-posts-i2__post-image-placeholder-icon" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + > + <title>{ __( 'Icon for image' ) }</title> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z" /> + </SVG> + </figure> + ) } + + { props.displayDate && ( + <div className="jp-related-posts-i2__post-date has-small-font-size"> + { __( 'August 3, 2018' ) } + </div> + ) } + { props.displayContext && ( + <div className="jp-related-posts-i2__post-context has-small-font-size"> + { __( 'In “Uncategorized”' ) } + </div> + ) } + </div> + ); +} + +function RelatedPostsEditItem( props ) { + return ( + <div + className="jp-related-posts-i2__post" + id={ props.id } + aria-labelledby={ props.id + '-heading' } + > + <a + className="jp-related-posts-i2__post-link" + id={ props.id + '-heading' } + href={ props.post.url } + rel="nofollow noopener noreferrer" + target="_blank" + > + { props.post.title } + </a> + { props.displayThumbnails && props.post.img && props.post.img.src && ( + <a className="jp-related-posts-i2__post-img-link" href={ props.post.url }> + <img + className="jp-related-posts-i2__post-img" + src={ props.post.img.src } + alt={ props.post.title } + rel="nofollow noopener noreferrer" + target="_blank" + /> + </a> + ) } + { props.displayDate && ( + <div className="jp-related-posts-i2__post-date has-small-font-size"> + { props.post.date } + </div> + ) } + { props.displayContext && ( + <div className="jp-related-posts-i2__post-context has-small-font-size"> + { props.post.context } + </div> + ) } + </div> + ); +} + +function RelatedPostsPreviewRows( props ) { + const className = 'jp-related-posts-i2__row'; + + let topRowEnd = 0; + const displayLowerRow = props.posts.length > 3; + + switch ( props.posts.length ) { + case 2: + case 4: + case 5: + topRowEnd = 2; + break; + default: + topRowEnd = 3; + break; + } + + return ( + <div> + <div className={ className } data-post-count={ props.posts.slice( 0, topRowEnd ).length }> + { props.posts.slice( 0, topRowEnd ) } + </div> + { displayLowerRow && ( + <div className={ className } data-post-count={ props.posts.slice( topRowEnd ).length }> + { props.posts.slice( topRowEnd ) } + </div> + ) } + </div> + ); +} + +class RelatedPostsEdit extends Component { + render() { + const { attributes, className, posts, setAttributes, instanceId } = this.props; + const { displayContext, displayDate, displayThumbnails, postLayout, postsToShow } = attributes; + + const layoutControls = [ + { + icon: 'grid-view', + title: __( 'Grid View' ), + onClick: () => setAttributes( { postLayout: 'grid' } ), + isActive: postLayout === 'grid', + }, + { + icon: 'list-view', + title: __( 'List View' ), + onClick: () => setAttributes( { postLayout: 'list' } ), + isActive: postLayout === 'list', + }, + ]; + + // To prevent the block from crashing, we need to limit ourselves to the + // posts returned by the backend - so if we want 6 posts, but only 3 are + // returned, we need to limit ourselves to those 3 and fill in the rest + // with placeholders. + // + // Also, if the site does not have sufficient posts to display related ones + // (minimum 10 posts), we also use this code block to fill in the + // placeholders. + const previewClassName = 'jp-relatedposts-i2'; + const displayPosts = []; + for ( let i = 0; i < postsToShow; i++ ) { + if ( posts[ i ] ) { + displayPosts.push( + <RelatedPostsEditItem + id={ `related-posts-${ instanceId }-post-${ i }` } + key={ previewClassName + '-' + i } + post={ posts[ i ] } + displayThumbnails={ displayThumbnails } + displayDate={ displayDate } + displayContext={ displayContext } + /> + ); + } else { + displayPosts.push( + <PlaceholderPostEdit + id={ `related-posts-${ instanceId }-post-${ i }` } + key={ 'related-post-placeholder-' + i } + displayThumbnails={ displayThumbnails } + displayDate={ displayDate } + displayContext={ displayContext } + /> + ); + } + } + + return ( + <Fragment> + <InspectorControls> + <PanelBody title={ __( 'Related Posts Settings' ) }> + <ToggleControl + label={ __( 'Display thumbnails' ) } + checked={ displayThumbnails } + onChange={ value => setAttributes( { displayThumbnails: value } ) } + /> + <ToggleControl + label={ __( 'Display date' ) } + checked={ displayDate } + onChange={ value => setAttributes( { displayDate: value } ) } + /> + <ToggleControl + label={ __( 'Display context (category or tag)' ) } + checked={ displayContext } + onChange={ value => setAttributes( { displayContext: value } ) } + /> + <RangeControl + label={ __( 'Number of posts' ) } + value={ postsToShow } + onChange={ value => + setAttributes( { postsToShow: Math.min( value, MAX_POSTS_TO_SHOW ) } ) + } + min={ 1 } + max={ MAX_POSTS_TO_SHOW } + /> + </PanelBody> + </InspectorControls> + + <BlockControls> + <Toolbar controls={ layoutControls } /> + </BlockControls> + + <div className={ className } id={ `related-posts-${ instanceId }` }> + <div className={ previewClassName } data-layout={ postLayout }> + <RelatedPostsPreviewRows posts={ displayPosts } /> + </div> + </div> + </Fragment> + ); + } +} + +export default compose( + withInstanceId, + withSelect( select => { + const { getCurrentPost } = select( 'core/editor' ); + const posts = get( getCurrentPost(), 'jetpack-related-posts', [] ); + + return { + posts, + }; + } ) +)( RelatedPostsEdit ); diff --git a/extensions/blocks/related-posts/editor.js b/extensions/blocks/related-posts/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/related-posts/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/related-posts/index.js b/extensions/blocks/related-posts/index.js new file mode 100644 index 0000000000000..486cad0f75f3e --- /dev/null +++ b/extensions/blocks/related-posts/index.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { G, Path, SVG } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; +import edit from './edit'; +import { __, _x } from '../../utils/i18n'; + +export const name = 'related-posts'; + +export const settings = { + title: __( 'Related Posts' ), + + icon: ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <G stroke="currentColor" strokeWidth="2" strokeLinecap="square"> + <Path d="M4,4 L4,19 M4,4 L19,4 M4,9 L19,9 M4,14 L19,14 M4,19 L19,19 M9,4 L9,19 M19,4 L19,19" /> + </G> + </SVG> + ), + + category: 'jetpack', + + keywords: [ + _x( 'Similar content', 'block search term' ), + _x( 'Linked', 'block search term' ), + _x( 'Connected', 'block search term' ), + ], + + attributes: { + postLayout: { + type: 'string', + default: 'grid', + }, + displayDate: { + type: 'boolean', + default: true, + }, + displayThumbnails: { + type: 'boolean', + default: false, + }, + displayContext: { + type: 'boolean', + default: false, + }, + postsToShow: { + type: 'number', + default: 3, + }, + }, + + supports: { + html: false, + multiple: false, + reusable: false, + }, + + transforms: { + from: [ + { + type: 'shortcode', + tag: 'jetpack-related-posts', + }, + ], + }, + + edit, + + save: () => null, +}; diff --git a/extensions/blocks/related-posts/style.scss b/extensions/blocks/related-posts/style.scss new file mode 100644 index 0000000000000..8008c57aad8dc --- /dev/null +++ b/extensions/blocks/related-posts/style.scss @@ -0,0 +1,87 @@ +// @TODO: Replace with Gutenberg variables + +.jp-related-posts-i2 { + &__row { + display: flex; + margin-top: 1.5rem; + + &:first-child { + margin-top: 0; + } + + &[data-post-count='3'] .jp-related-posts-i2__post { + max-width: calc( 33% - 20px ); + } + + &[data-post-count='2'] .jp-related-posts-i2__post, + &[data-post-count='1'] .jp-related-posts-i2__post { + max-width: calc( 50% - 20px ); + } + } + + &__post { + flex-grow: 1; + flex-basis: 0; + margin: 0 10px; + display: flex; + flex-direction: column; + } + + &__post-heading, &__post-img-link, &__post-date, &__post-context { + flex-direction: row; + } + + &__post-img-link, &__post-image-placeholder { + order: -1; + } + + &__post-heading { + margin: 0.5rem 0; + font-size: 1rem; + line-height: 1.2em; + } + + &__post-link { + display: block; + width: 100%; + line-height: 1.2em; + margin: 0.2em 0; + } + + &__post-img { + width: 100%; + } + + &__post-image-placeholder { + display: block; + position: relative; + margin: 0 auto; + max-width: 350px; + &-icon { + position: absolute; + top: calc( 50% - 12px ); + left: calc( 50% - 12px ); + } + } +} + +/* List view */ + +.jp-relatedposts-i2[data-layout='list'] { + .jp-related-posts-i2__row { + margin-top: 0; + display: block; + } + .jp-related-posts-i2__post { + max-width: none; + margin: 0; + margin-top: 1rem; + } + .jp-related-posts-i2__post-image-placeholder { + max-width: 350px; + margin: 0; + } + .jp-related-posts-i2__post-img-link { + margin-top: 1rem; + } +} diff --git a/extensions/blocks/repeat-visitor/components/edit.js b/extensions/blocks/repeat-visitor/components/edit.js new file mode 100644 index 0000000000000..2ad72b4e00930 --- /dev/null +++ b/extensions/blocks/repeat-visitor/components/edit.js @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import { Notice, TextControl, RadioControl, Placeholder } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { InnerBlocks } from '@wordpress/editor'; +import { withSelect } from '@wordpress/data'; +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import { sprintf } from '@wordpress/i18n'; +import { __, _n } from '../../../utils/i18n'; +import { CRITERIA_AFTER, CRITERIA_BEFORE } from '../constants'; +import { icon } from '../index'; + +const RADIO_OPTIONS = [ + { + value: CRITERIA_AFTER, + label: __( 'Show after threshold' ), + }, + { + value: CRITERIA_BEFORE, + label: __( 'Show before threshold' ), + }, +]; + +class RepeatVisitorEdit extends Component { + componentDidMount() { + this.props.setAttributes( { isThresholdValid: true } ); + } + + setCriteria = criteria => this.props.setAttributes( { criteria } ); + setThreshold = threshold => { + if ( /^\d+$/.test( threshold ) && +threshold > 0 ) { + this.props.setAttributes( { threshold, isThresholdValid: true } ); + return; + } + this.props.setAttributes( { isThresholdValid: false } ); + }; + + getNoticeLabel() { + if ( this.props.attributes.criteria === CRITERIA_AFTER ) { + return sprintf( + _n( + 'This block will only appear to people who have visited this page at least once.', + 'This block will only appear to people who have visited this page at least %d times.', + +this.props.attributes.threshold + ), + this.props.attributes.threshold + ); + } + + return sprintf( + _n( + 'This block will only appear to people who have never visited this page before.', + 'This block will only appear to people who have visited this page less than %d times.', + +this.props.attributes.threshold + ), + this.props.attributes.threshold + ); + } + + render() { + return ( + <div + className={ classNames( this.props.className, { + 'wp-block-jetpack-repeat-visitor--is-unselected': ! this.props.isSelected, + } ) } + > + <Placeholder + icon={ icon } + label={ __( 'Repeat Visitor' ) } + className="wp-block-jetpack-repeat-visitor-placeholder" + > + <TextControl + className="wp-block-jetpack-repeat-visitor-threshold" + defaultValue={ this.props.attributes.threshold } + help={ + this.props.attributes.isThresholdValid ? '' : __( 'Please enter a valid number.' ) + } + label={ __( 'Visit count threshold' ) } + min="1" + onChange={ this.setThreshold } + pattern="[0-9]" + type="number" + /> + + <RadioControl + label={ __( 'Visibility' ) } + selected={ this.props.attributes.criteria } + options={ RADIO_OPTIONS } + onChange={ this.setCriteria } + /> + </Placeholder> + + <Notice status="info" isDismissible={ false }> + { this.getNoticeLabel() } + </Notice> + <InnerBlocks /> + </div> + ); + } +} + +export default withSelect( ( select, ownProps ) => { + const { isBlockSelected, hasSelectedInnerBlock } = select( 'core/editor' ); + return { + isSelected: isBlockSelected( ownProps.clientId ) || hasSelectedInnerBlock( ownProps.clientId ), + }; +} )( RepeatVisitorEdit ); diff --git a/extensions/blocks/repeat-visitor/components/save.js b/extensions/blocks/repeat-visitor/components/save.js new file mode 100644 index 0000000000000..7484c06d9e5f9 --- /dev/null +++ b/extensions/blocks/repeat-visitor/components/save.js @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import { InnerBlocks } from '@wordpress/editor'; + +export default ( { className } ) => { + return ( + <div className={ className }> + <InnerBlocks.Content /> + </div> + ); +}; diff --git a/extensions/blocks/repeat-visitor/constants.js b/extensions/blocks/repeat-visitor/constants.js new file mode 100644 index 0000000000000..09f459d2947d7 --- /dev/null +++ b/extensions/blocks/repeat-visitor/constants.js @@ -0,0 +1,5 @@ +export const CRITERIA_AFTER = 'after-visits'; +export const CRITERIA_BEFORE = 'before-visits'; +export const DEFAULT_THRESHOLD = 3; +export const COOKIE_NAME = 'jp-visit-counter'; +export const MAX_COOKIE_AGE = 6 * 30 * 24 * 60 * 60; // 6 months diff --git a/extensions/blocks/repeat-visitor/editor.js b/extensions/blocks/repeat-visitor/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/repeat-visitor/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/repeat-visitor/editor.scss b/extensions/blocks/repeat-visitor/editor.scss new file mode 100644 index 0000000000000..72b90c39d3d3c --- /dev/null +++ b/extensions/blocks/repeat-visitor/editor.scss @@ -0,0 +1,54 @@ +.wp-block-jetpack-repeat-visitor { + .components-notice { + margin: 1em 0 0; + } + .components-radio-control__option { + text-align: left; + } + .components-notice__content { + margin: 0.5em 0; + font-size: 0.8em; + + .components-base-control { + display: inline-block; + max-width: 8em; + vertical-align: middle; + + .components-base-control__field { + margin-bottom: 0; + } + } + } +} + +.wp-block-jetpack-repeat-visitor-placeholder { + min-height: inherit; + + .components-placeholder__label svg { + margin-right: 0.5ch; + } + + .components-placeholder__fieldset { + flex-wrap: nowrap; + .components-base-control { + flex-basis: 100%; + } + } + + .components-base-control__help { + color: $muriel-hot-red-500; + } +} + +.wp-block-jetpack-repeat-visitor--is-unselected .wp-block-jetpack-repeat-visitor-placeholder { + display: none; +} + +.wp-block-jetpack-repeat-visitor-threshold { + margin-right: 20px; + + .components-text-control__input { + width: 5em; + text-align: center; + } +} diff --git a/extensions/blocks/repeat-visitor/index.js b/extensions/blocks/repeat-visitor/index.js new file mode 100644 index 0000000000000..52246fa2a5b60 --- /dev/null +++ b/extensions/blocks/repeat-visitor/index.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { Path } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '../../utils/i18n'; +import renderMaterialIcon from '../../utils/render-material-icon'; +import edit from './components/edit'; +import save from './components/save'; +import { CRITERIA_AFTER, DEFAULT_THRESHOLD } from './constants'; +import './editor.scss'; + +export const name = 'repeat-visitor'; +export const icon = renderMaterialIcon( + <Path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" /> +); +export const settings = { + attributes: { + criteria: { + type: 'string', + default: CRITERIA_AFTER, + }, + threshold: { + type: 'number', + default: DEFAULT_THRESHOLD, + }, + isThresholdValid: { + type: 'boolean', + default: true, + }, + }, + category: 'jetpack', + description: __( 'Control block visibility based on how often a visitor has viewed the page.' ), + icon, + keywords: [ + _x( 'traffic', 'block search term' ), + _x( 'visitors', 'block search term' ), + _x( 'visibility', 'block search term' ), + ], + supports: { html: false }, + title: __( 'Repeat Visitor' ), + edit, + save, +}; diff --git a/extensions/blocks/repeat-visitor/view.js b/extensions/blocks/repeat-visitor/view.js new file mode 100644 index 0000000000000..0273932ca04e1 --- /dev/null +++ b/extensions/blocks/repeat-visitor/view.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import cookie from 'cookie'; + +/** + * Internal dependencies + */ +import { COOKIE_NAME, MAX_COOKIE_AGE } from './constants'; + +function getViewCount() { + const cookies = cookie.parse( document.cookie ); + const value = cookies[ COOKIE_NAME ] || 0; + return +value; +} + +function setViewCount( value ) { + document.cookie = cookie.serialize( COOKIE_NAME, value, { + path: window.location.pathname, + maxAge: MAX_COOKIE_AGE, + } ); +} + +function incrementCookieValue() { + const repeatVisitorBlocks = Array.from( + document.querySelectorAll( '.wp-block-jetpack-repeat-visitor' ) + ); + if ( repeatVisitorBlocks.length === 0 ) { + return; + } + + setViewCount( getViewCount() + 1 ); +} + +window && window.addEventListener( 'load', incrementCookieValue ); diff --git a/extensions/blocks/seo/editor.js b/extensions/blocks/seo/editor.js new file mode 100644 index 0000000000000..e2811b0c0d125 --- /dev/null +++ b/extensions/blocks/seo/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import { name, settings } from '.'; +import registerJetpackPlugin from '../../utils/register-jetpack-plugin'; + +registerJetpackPlugin( name, settings ); diff --git a/extensions/blocks/seo/editor.scss b/extensions/blocks/seo/editor.scss new file mode 100644 index 0000000000000..ea00e7faf8f50 --- /dev/null +++ b/extensions/blocks/seo/editor.scss @@ -0,0 +1,16 @@ +// @TODO: Replace with Gutenberg variables +$light-gray-300: #edeff0; + +.jetpack-seo-message-box { + background-color: $light-gray-300; + border-radius: 4px; +} + +.jetpack-seo-message-box textarea { + width: 100%; +} + +.jetpack-seo-character-count { + padding-bottom: 5px; + padding-left: 5px; +} diff --git a/extensions/blocks/seo/index.js b/extensions/blocks/seo/index.js new file mode 100644 index 0000000000000..12747d5d79b17 --- /dev/null +++ b/extensions/blocks/seo/index.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { Fragment } from '@wordpress/element'; +import { PanelBody } from '@wordpress/components'; +import { PluginPrePublishPanel } from '@wordpress/edit-post'; + +/** + * Internal dependencies + */ +import './editor.scss'; +import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar'; +import SeoPanel from './panel'; +import { __ } from '../../utils/i18n'; + +export const name = 'seo'; + +export const settings = { + render: () => ( + <Fragment> + <JetpackPluginSidebar> + <PanelBody title={ __( 'SEO Description' ) }> + <SeoPanel /> + </PanelBody> + </JetpackPluginSidebar> + <PluginPrePublishPanel + initialOpen + id="seo-title" + title={ + <span id="seo-defaults" key="seo-title-span"> + { __( 'SEO Description' ) } + </span> + } + > + <SeoPanel /> + </PluginPrePublishPanel> + </Fragment> + ), +}; diff --git a/extensions/blocks/seo/panel.js b/extensions/blocks/seo/panel.js new file mode 100644 index 0000000000000..7c408791910d7 --- /dev/null +++ b/extensions/blocks/seo/panel.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { get } from 'lodash'; +import { sprintf } from '@wordpress/i18n'; +import { withDispatch, withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { __, _n } from '../../utils/i18n'; + +class SeoPanel extends Component { + onMessageChange = event => { + this.props.updateSeoDescription( event.target.value ); + }; + + render() { + const { seoDescription } = this.props; + + return ( + <div className="jetpack-seo-message-box"> + <textarea + value={ seoDescription } + onChange={ this.onMessageChange } + placeholder={ __( 'Write a description…' ) } + rows={ 4 } + /> + <div className="jetpack-seo-character-count"> + { sprintf( + _n( '%d character', '%d characters', seoDescription.length ), + seoDescription.length + ) } + </div> + </div> + ); + } +} + +export default compose( [ + withSelect( select => ( { + seoDescription: get( + select( 'core/editor' ).getEditedPostAttribute( 'meta' ), + [ 'advanced_seo_description' ], + '' + ), + } ) ), + withDispatch( dispatch => ( { + updateSeoDescription( seoDescription ) { + dispatch( 'core/editor' ).editPost( { + meta: { + advanced_seo_description: seoDescription, + }, + } ); + }, + } ) ), +] )( SeoPanel ); diff --git a/extensions/blocks/shortlinks/editor.js b/extensions/blocks/shortlinks/editor.js new file mode 100644 index 0000000000000..e2811b0c0d125 --- /dev/null +++ b/extensions/blocks/shortlinks/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import { name, settings } from '.'; +import registerJetpackPlugin from '../../utils/register-jetpack-plugin'; + +registerJetpackPlugin( name, settings ); diff --git a/extensions/blocks/shortlinks/index.js b/extensions/blocks/shortlinks/index.js new file mode 100644 index 0000000000000..11effa9ccf114 --- /dev/null +++ b/extensions/blocks/shortlinks/index.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { get } from 'lodash'; +import { PanelBody } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import ClipboardInput from '../../utils/clipboard-input'; +import JetpackPluginSidebar from '../../shared/jetpack-plugin-sidebar'; +import { __ } from '../../utils/i18n'; + +export const name = 'shortlinks'; + +export const settings = { + render: () => <Shortlinks />, +}; + +class ShortlinksPanel extends Component { + render() { + const { shortlink } = this.props; + + if ( ! shortlink ) { + return null; + } + + return ( + <JetpackPluginSidebar> + <PanelBody title={ __( 'Shortlink' ) } className="jetpack-shortlinks__panel"> + <ClipboardInput link={ shortlink } /> + </PanelBody> + </JetpackPluginSidebar> + ); + } +} + +const Shortlinks = withSelect( select => { + const currentPost = select( 'core/editor' ).getCurrentPost(); + return { + shortlink: get( currentPost, 'jetpack_shortlink', '' ), + }; +} )( ShortlinksPanel ); diff --git a/extensions/blocks/simple-payments/constants.js b/extensions/blocks/simple-payments/constants.js new file mode 100644 index 0000000000000..e593f9476210a --- /dev/null +++ b/extensions/blocks/simple-payments/constants.js @@ -0,0 +1,39 @@ +export const SIMPLE_PAYMENTS_PRODUCT_POST_TYPE = 'jp_pay_product'; + +export const DEFAULT_CURRENCY = 'USD'; + +// https://developer.paypal.com/docs/integration/direct/rest/currency-codes/ +// If this list changes, Simple Payments in Jetpack must be updated as well. +// See https://github.com/Automattic/jetpack/blob/master/modules/simple-payments/simple-payments.php + +/** + * Indian Rupee not supported because at the time of the creation of this file + * because it's limited to in-country PayPal India accounts only. + * Discussion: https://github.com/Automattic/wp-calypso/pull/28236 + */ +export const SUPPORTED_CURRENCY_LIST = [ + DEFAULT_CURRENCY, + 'EUR', + 'AUD', + 'BRL', + 'CAD', + 'CZK', + 'DKK', + 'HKD', + 'HUF', + 'ILS', + 'JPY', + 'MYR', + 'MXN', + 'TWD', + 'NZD', + 'NOK', + 'PHP', + 'PLN', + 'GBP', + 'RUB', + 'SGD', + 'SEK', + 'CHF', + 'THB', +]; diff --git a/extensions/blocks/simple-payments/edit.js b/extensions/blocks/simple-payments/edit.js new file mode 100644 index 0000000000000..9f71c6e1a1a12 --- /dev/null +++ b/extensions/blocks/simple-payments/edit.js @@ -0,0 +1,578 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; +import emailValidator from 'email-validator'; +import { Component } from '@wordpress/element'; +import { compose, withInstanceId } from '@wordpress/compose'; +import { dispatch, withSelect } from '@wordpress/data'; +import { get, isEqual, pick, trimEnd } from 'lodash'; +import { sprintf } from '@wordpress/i18n'; +import { + Disabled, + ExternalLink, + SelectControl, + TextareaControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import { getCurrencyDefaults } from '@automattic/format-currency'; + +/** + * Internal dependencies + */ +import HelpMessage from './help-message'; +import ProductPlaceholder from './product-placeholder'; +import FeaturedMedia from './featured-media'; +import { __, _n } from '../../utils/i18n'; +import { decimalPlaces, formatPrice } from './utils'; +import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, SUPPORTED_CURRENCY_LIST } from './constants'; + +class SimplePaymentsEdit extends Component { + state = { + fieldEmailError: null, + fieldPriceError: null, + fieldTitleError: null, + isSavingProduct: false, + }; + + /** + * We'll use this flag to inject attributes one time when the product entity is loaded. + * + * It is based on the presence of a `productId` attribute. + * + * If present, initially we are waiting for attributes to be injected. + * If absent, we may save the product in the future but do not need to inject attributes based + * on the response as they will have come from our product submission. + */ + shouldInjectPaymentAttributes = !! this.props.attributes.productId; + + componentDidMount() { + // Try to get the simplePayment loaded into attributes if possible. + this.injectPaymentAttributes(); + + const { attributes, hasPublishAction } = this.props; + const { productId } = attributes; + + // If the user can publish save an empty product so that we have an ID and can save + // concurrently with the post that contains the Simple Payment. + if ( ! productId && hasPublishAction ) { + this.saveProduct(); + } + } + + componentDidUpdate( prevProps ) { + const { hasPublishAction, isSelected } = this.props; + + if ( ! isEqual( prevProps.simplePayment, this.props.simplePayment ) ) { + this.injectPaymentAttributes(); + } + + if ( + ! prevProps.isSaving && + this.props.isSaving && + hasPublishAction && + this.validateAttributes() + ) { + // Validate and save product on post save + this.saveProduct(); + } else if ( prevProps.isSelected && ! isSelected ) { + // Validate on block deselect + this.validateAttributes(); + } + } + + injectPaymentAttributes() { + /** + * Prevent injecting the product attributes when not desired. + * + * When we first load a product, we should inject its attributes as our initial form state. + * When subsequent saves occur, we should avoid injecting attributes so that we do not + * overwrite changes that the user has made with stale state from the previous save. + */ + if ( ! this.shouldInjectPaymentAttributes ) { + return; + } + + const { attributes, setAttributes, simplePayment } = this.props; + const { + content, + currency, + email, + featuredMediaId, + multiple, + price, + productId, + title, + } = attributes; + + if ( productId && simplePayment ) { + setAttributes( { + content: get( simplePayment, [ 'content', 'raw' ], content ), + currency: get( simplePayment, [ 'meta', 'spay_currency' ], currency ), + email: get( simplePayment, [ 'meta', 'spay_email' ], email ), + featuredMediaId: get( simplePayment, [ 'featured_media' ], featuredMediaId ), + multiple: Boolean( get( simplePayment, [ 'meta', 'spay_multiple' ], Boolean( multiple ) ) ), + price: get( simplePayment, [ 'meta', 'spay_price' ], price || undefined ), + title: get( simplePayment, [ 'title', 'raw' ], title ), + } ); + this.shouldInjectPaymentAttributes = ! this.shouldInjectPaymentAttributes; + } + } + + toApi() { + const { attributes } = this.props; + const { + content, + currency, + email, + featuredMediaId, + multiple, + price, + productId, + title, + } = attributes; + + return { + id: productId, + content, + featured_media: featuredMediaId, + meta: { + spay_currency: currency, + spay_email: email, + spay_multiple: multiple, + spay_price: price, + }, + status: productId ? 'publish' : 'draft', + title, + }; + } + + saveProduct() { + if ( this.state.isSavingProduct ) { + return; + } + + const { attributes, setAttributes } = this.props; + const { email } = attributes; + const { saveEntityRecord } = dispatch( 'core' ); + + this.setState( { isSavingProduct: true }, () => { + saveEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, this.toApi() ) + .then( record => { + if ( record ) { + setAttributes( { productId: record.id } ); + } + + return record; + } ) + .catch( error => { + // Nothing we can do about errors without details at the moment + if ( ! error || ! error.data ) { + return; + } + + const { + data: { key: apiErrorKey }, + } = error; + + // @TODO errors in other fields + this.setState( { + fieldEmailError: + apiErrorKey === 'spay_email' + ? sprintf( __( '%s is not a valid email address.' ), email ) + : null, + fieldPriceError: apiErrorKey === 'spay_price' ? __( 'Invalid price.' ) : null, + } ); + } ) + .finally( () => { + this.setState( { + isSavingProduct: false, + } ); + } ); + } ); + } + + validateAttributes = () => { + const isPriceValid = this.validatePrice(); + const isTitleValid = this.validateTitle(); + const isEmailValid = this.validateEmail(); + const isCurrencyValid = this.validateCurrency(); + + return isPriceValid && isTitleValid && isEmailValid && isCurrencyValid; + }; + + /** + * Validate currency + * + * This method does not include validation UI. Currency selection should not allow for invalid + * values. It is primarily to ensure that the currency is valid to save. + * + * @return {boolean} True if currency is valid + */ + validateCurrency = () => { + const { currency } = this.props.attributes; + return SUPPORTED_CURRENCY_LIST.includes( currency ); + }; + + /** + * Validate price + * + * Stores error message in state.fieldPriceError + * + * @returns {Boolean} True when valid, false when invalid + */ + validatePrice = () => { + const { currency, price } = this.props.attributes; + const { precision } = getCurrencyDefaults( currency ); + + if ( ! price || parseFloat( price ) === 0 ) { + this.setState( { + fieldPriceError: __( 'If you’re selling something, you need a price tag. Add yours here.' ), + } ); + return false; + } + + if ( Number.isNaN( parseFloat( price ) ) ) { + this.setState( { + fieldPriceError: __( 'Invalid price' ), + } ); + return false; + } + + if ( parseFloat( price ) < 0 ) { + this.setState( { + fieldPriceError: __( + 'Your price is negative — enter a positive number so people can pay the right amount.' + ), + } ); + return false; + } + + if ( decimalPlaces( price ) > precision ) { + if ( precision === 0 ) { + this.setState( { + fieldPriceError: __( + 'We know every penny counts, but prices in this currency can’t contain decimal values.' + ), + } ); + return false; + } + + this.setState( { + fieldPriceError: sprintf( + _n( + 'The price cannot have more than %d decimal place.', + 'The price cannot have more than %d decimal places.', + precision + ), + precision + ), + } ); + return false; + } + + if ( this.state.fieldPriceError ) { + this.setState( { fieldPriceError: null } ); + } + + return true; + }; + + /** + * Validate email + * + * Stores error message in state.fieldEmailError + * + * @returns {Boolean} True when valid, false when invalid + */ + validateEmail = () => { + const { email } = this.props.attributes; + if ( ! email ) { + this.setState( { + fieldEmailError: __( + 'We want to make sure payments reach you, so please add an email address.' + ), + } ); + return false; + } + + if ( ! emailValidator.validate( email ) ) { + this.setState( { + fieldEmailError: sprintf( __( '%s is not a valid email address.' ), email ), + } ); + return false; + } + + if ( this.state.fieldEmailError ) { + this.setState( { fieldEmailError: null } ); + } + + return true; + }; + + /** + * Validate title + * + * Stores error message in state.fieldTitleError + * + * @returns {Boolean} True when valid, false when invalid + */ + validateTitle = () => { + const { title } = this.props.attributes; + if ( ! title ) { + this.setState( { + fieldTitleError: __( + 'Please add a brief title so that people know what they’re paying for.' + ), + } ); + return false; + } + + if ( this.state.fieldTitleError ) { + this.setState( { fieldTitleError: null } ); + } + + return true; + }; + + handleEmailChange = email => { + this.props.setAttributes( { email } ); + this.setState( { fieldEmailError: null } ); + }; + + handleFeaturedMediaSelect = media => { + this.props.setAttributes( { featuredMediaId: get( media, 'id', 0 ) } ); + }; + + handleContentChange = content => { + this.props.setAttributes( { content } ); + }; + + handlePriceChange = price => { + price = parseFloat( price ); + if ( ! isNaN( price ) ) { + this.props.setAttributes( { price } ); + } else { + this.props.setAttributes( { price: undefined } ); + } + this.setState( { fieldPriceError: null } ); + }; + + handleCurrencyChange = currency => { + this.props.setAttributes( { currency } ); + }; + + handleMultipleChange = multiple => { + this.props.setAttributes( { multiple: !! multiple } ); + }; + + handleTitleChange = title => { + this.props.setAttributes( { title } ); + this.setState( { fieldTitleError: null } ); + }; + + getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => { + const { symbol } = getCurrencyDefaults( value ); + // if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it. + // trim the dot at the end, e.g., 'kr.' becomes 'kr' + const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`; + return { value, label }; + } ); + + render() { + const { fieldEmailError, fieldPriceError, fieldTitleError } = this.state; + const { + attributes, + featuredMedia, + instanceId, + isSelected, + setAttributes, + simplePayment, + } = this.props; + const { + content, + currency, + email, + featuredMediaId, + featuredMediaUrl: featuredMediaUrlAttribute, + featuredMediaTitle: featuredMediaTitleAttribute, + multiple, + price, + productId, + title, + } = attributes; + + const featuredMediaUrl = + featuredMediaUrlAttribute || ( featuredMedia && featuredMedia.source_url ); + const featuredMediaTitle = + featuredMediaTitleAttribute || ( featuredMedia && featuredMedia.alt_text ); + + /** + * The only disabled state that concerns us is when we expect a product but don't have it in + * local state. + */ + const isDisabled = productId && ! simplePayment; + + if ( ! isSelected && isDisabled ) { + return ( + <div className="simple-payments__loading"> + <ProductPlaceholder + aria-busy="true" + content="█████" + formattedPrice="█████" + title="█████" + /> + </div> + ); + } + + if ( + ! isSelected && + email && + price && + title && + ! fieldEmailError && + ! fieldPriceError && + ! fieldTitleError + ) { + return ( + <ProductPlaceholder + aria-busy="false" + content={ content } + featuredMediaUrl={ featuredMediaUrl } + featuredMediaTitle={ featuredMediaTitle } + formattedPrice={ formatPrice( price, currency ) } + multiple={ multiple } + title={ title } + /> + ); + } + + const Wrapper = isDisabled ? Disabled : 'div'; + + return ( + <Wrapper className="wp-block-jetpack-simple-payments"> + <FeaturedMedia + { ...{ featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } } + /> + <div> + <TextControl + aria-describedby={ `${ instanceId }-title-error` } + className={ classNames( 'simple-payments__field', 'simple-payments__field-title', { + 'simple-payments__field-has-error': fieldTitleError, + } ) } + label={ __( 'Item name' ) } + onChange={ this.handleTitleChange } + placeholder={ __( 'Item name' ) } + required + type="text" + value={ title } + /> + <HelpMessage id={ `${ instanceId }-title-error` } isError> + { fieldTitleError } + </HelpMessage> + + <TextareaControl + className="simple-payments__field simple-payments__field-content" + label={ __( 'Describe your item in a few words' ) } + onChange={ this.handleContentChange } + placeholder={ __( 'Describe your item in a few words' ) } + value={ content } + /> + + <div className="simple-payments__price-container"> + <SelectControl + className="simple-payments__field simple-payments__field-currency" + label={ __( 'Currency' ) } + onChange={ this.handleCurrencyChange } + options={ this.getCurrencyList } + value={ currency } + /> + <TextControl + aria-describedby={ `${ instanceId }-price-error` } + className={ classNames( 'simple-payments__field', 'simple-payments__field-price', { + 'simple-payments__field-has-error': fieldPriceError, + } ) } + label={ __( 'Price' ) } + onChange={ this.handlePriceChange } + placeholder={ formatPrice( 0, currency, false ) } + required + step="1" + type="number" + value={ price || '' } + /> + <HelpMessage id={ `${ instanceId }-price-error` } isError> + { fieldPriceError } + </HelpMessage> + </div> + + <div className="simple-payments__field-multiple"> + <ToggleControl + checked={ Boolean( multiple ) } + label={ __( 'Allow people to buy more than one item at a time' ) } + onChange={ this.handleMultipleChange } + /> + </div> + + <TextControl + aria-describedby={ `${ instanceId }-email-${ fieldEmailError ? 'error' : 'help' }` } + className={ classNames( 'simple-payments__field', 'simple-payments__field-email', { + 'simple-payments__field-has-error': fieldEmailError, + } ) } + label={ __( 'Email' ) } + onChange={ this.handleEmailChange } + placeholder={ __( 'Email' ) } + required + type="email" + value={ email } + /> + <HelpMessage id={ `${ instanceId }-email-error` } isError> + { fieldEmailError } + </HelpMessage> + <HelpMessage id={ `${ instanceId }-email-help` }> + { __( + 'Enter the email address associated with your PayPal account. Don’t have an account?' + ) + ' ' } + <ExternalLink href="https://www.paypal.com/"> + { __( 'Create one on PayPal' ) } + </ExternalLink> + </HelpMessage> + </div> + </Wrapper> + ); + } +} + +const mapSelectToProps = withSelect( ( select, props ) => { + const { getEntityRecord, getMedia } = select( 'core' ); + const { isSavingPost, getCurrentPost } = select( 'core/editor' ); + + const { productId, featuredMediaId } = props.attributes; + + const fields = [ + [ 'content' ], + [ 'meta', 'spay_currency' ], + [ 'meta', 'spay_email' ], + [ 'meta', 'spay_multiple' ], + [ 'meta', 'spay_price' ], + [ 'title', 'raw' ], + [ 'featured_media' ], + ]; + + const simplePayment = productId + ? pick( getEntityRecord( 'postType', SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, productId ), fields ) + : undefined; + + return { + hasPublishAction: !! get( getCurrentPost(), [ '_links', 'wp:action-publish' ] ), + isSaving: !! isSavingPost(), + simplePayment, + featuredMedia: featuredMediaId ? getMedia( featuredMediaId ) : null, + }; +} ); + +export default compose( + mapSelectToProps, + withInstanceId +)( SimplePaymentsEdit ); diff --git a/extensions/blocks/simple-payments/editor.js b/extensions/blocks/simple-payments/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/simple-payments/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/simple-payments/editor.scss b/extensions/blocks/simple-payments/editor.scss new file mode 100644 index 0000000000000..76d6fd9d065fb --- /dev/null +++ b/extensions/blocks/simple-payments/editor.scss @@ -0,0 +1,62 @@ + +.wp-block-jetpack-simple-payments { + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell, + Helvetica Neue, sans-serif; + display: grid; + grid-template-columns: 200px auto; + grid-column-gap: 10px; + + .simple-payments__field { + .components-base-control__label { + display: none; + } + .components-base-control__field { + margin-bottom: 1em; + } + // Reset empty space under textarea on Chrome + textarea { + display: block; + } + } + + .simple-payments__field-has-error { + .components-text-control__input, + .components-textarea-control__input { + border-color: var( --color-error ); + } + } + + .simple-payments__price-container { + display: flex; + flex-wrap: wrap; + .simple-payments__field { + margin-right: 10px; + } + .simple-payments__help-message { + flex: 1 1 100%; + margin-top: 0; + } + } + + .simple-payments__field-price { + .components-text-control__input { + max-width: 90px; + } + } + + .simple-payments__field-email { + .components-text-control__input { + max-width: 400px; + } + } + + .simple-payments__field-multiple { + .components-toggle-control__label { + line-height: 1.4em; + } + } + + .simple-payments__field-content .components-textarea-control__input { + min-height: 32px; + } +} diff --git a/extensions/blocks/simple-payments/featured-media.js b/extensions/blocks/simple-payments/featured-media.js new file mode 100644 index 0000000000000..4737b85100047 --- /dev/null +++ b/extensions/blocks/simple-payments/featured-media.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { Fragment } from '@wordpress/element'; +import { IconButton, Toolbar, ToolbarButton } from '@wordpress/components'; +import { BlockControls, MediaPlaceholder, MediaUpload } from '@wordpress/editor'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +const onSelectMedia = setAttributes => media => + setAttributes( { + featuredMediaId: get( media, 'id', 0 ), + featuredMediaUrl: get( media, 'url', null ), + featuredMediaTitle: get( media, 'title', null ), + } ); + +export default ( { featuredMediaId, featuredMediaUrl, featuredMediaTitle, setAttributes } ) => { + if ( ! featuredMediaId ) { + return ( + <MediaPlaceholder + icon="format-image" + labels={ { + title: __( 'Product Image' ), + } } + accept="image/*" + allowedTypes={ [ 'image' ] } + onSelect={ onSelectMedia( setAttributes ) } + /> + ); + } + + return ( + <div> + <Fragment> + <BlockControls> + <Toolbar> + <MediaUpload + onSelect={ onSelectMedia( setAttributes ) } + allowedTypes={ [ 'image' ] } + value={ featuredMediaId } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit Image' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + <ToolbarButton + icon={ 'trash' } + title={ __( 'Remove Image' ) } + onClick={ () => + setAttributes( { + featuredMediaId: null, + featuredMediaUrl: null, + featuredMediaTitle: null, + } ) + } + /> + </Toolbar> + </BlockControls> + <figure> + <img src={ featuredMediaUrl } alt={ featuredMediaTitle } /> + </figure> + </Fragment> + </div> + ); +}; diff --git a/extensions/blocks/simple-payments/help-message.js b/extensions/blocks/simple-payments/help-message.js new file mode 100644 index 0000000000000..57a6e6819ebb1 --- /dev/null +++ b/extensions/blocks/simple-payments/help-message.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import GridiconNoticeOutline from 'gridicons/dist/notice-outline'; +import './help-message.scss'; + +export default ( { children = null, isError = false, ...props } ) => { + const classes = classNames( 'simple-payments__help-message', { + 'simple-payments__help-message-is-error': isError, + } ); + + return ( + children && ( + <div className={ classes } { ...props }> + { isError && <GridiconNoticeOutline size="24" /> } + <span>{ children }</span> + </div> + ) + ); +}; diff --git a/extensions/blocks/simple-payments/help-message.scss b/extensions/blocks/simple-payments/help-message.scss new file mode 100644 index 0000000000000..86f50f9e3b9b7 --- /dev/null +++ b/extensions/blocks/simple-payments/help-message.scss @@ -0,0 +1,23 @@ + +.wp-block-jetpack-simple-payments { + .simple-payments__help-message { + display: flex; + font-size: 13px; + line-height: 1.4em; + margin-bottom: 1em; + margin-top: -0.5em; + svg { + margin-right: 5px; + min-width: 24px; + } + > span { + margin-top: 2px; + } + &.simple-payments__help-message-is-error { + color: var( --color-error ); + svg { + fill: var( --color-error ); + } + } + } +} diff --git a/extensions/blocks/simple-payments/index.js b/extensions/blocks/simple-payments/index.js new file mode 100644 index 0000000000000..223c20c823e19 --- /dev/null +++ b/extensions/blocks/simple-payments/index.js @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { ExternalLink, Path, SVG } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import { DEFAULT_CURRENCY } from './constants'; +import { __, _x } from '../../utils/i18n'; + +/** + * Styles + */ +import './editor.scss'; + +export const name = 'simple-payments'; + +export const settings = { + title: __( 'Simple Payments button' ), + + description: ( + <Fragment> + <p> + { __( + 'Lets you create and embed credit and debit card payment buttons with minimal setup.' + ) } + </p> + <ExternalLink href="https://support.wordpress.com/simple-payments/"> + { __( 'Support reference' ) } + </ExternalLink> + </Fragment> + ), + + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" /> + </SVG> + ), + + category: 'jetpack', + + keywords: [ _x( 'shop', 'block search term' ), _x( 'sell', 'block search term' ), 'PayPal' ], + + attributes: { + currency: { + type: 'string', + default: DEFAULT_CURRENCY, + }, + content: { + type: 'string', + default: '', + }, + email: { + type: 'string', + default: '', + }, + featuredMediaId: { + type: 'number', + default: 0, + }, + featuredMediaUrl: { + type: 'string', + default: null, + }, + featuredMediaTitle: { + type: 'string', + default: null, + }, + multiple: { + type: 'boolean', + default: false, + }, + price: { + type: 'number', + }, + productId: { + type: 'number', + }, + title: { + type: 'string', + default: '', + }, + }, + + transforms: { + from: [ + { + type: 'shortcode', + tag: 'simple-payment', + attributes: { + productId: { + type: 'number', + shortcode: ( { named: { id } } ) => { + if ( ! id ) { + return; + } + + const result = parseInt( id, 10 ); + if ( result ) { + return result; + } + }, + }, + }, + }, + ], + }, + + edit, + + save, + + supports: { + className: false, + customClassName: false, + html: false, + reusable: false, + }, +}; diff --git a/extensions/blocks/simple-payments/paypal-button-2x.png b/extensions/blocks/simple-payments/paypal-button-2x.png new file mode 100644 index 0000000000000..ceea141d3ae93 Binary files /dev/null and b/extensions/blocks/simple-payments/paypal-button-2x.png differ diff --git a/extensions/blocks/simple-payments/paypal-button.png b/extensions/blocks/simple-payments/paypal-button.png new file mode 100644 index 0000000000000..13bbad02e25fa Binary files /dev/null and b/extensions/blocks/simple-payments/paypal-button.png differ diff --git a/extensions/blocks/simple-payments/product-placeholder.js b/extensions/blocks/simple-payments/product-placeholder.js new file mode 100644 index 0000000000000..c8f82870ed03e --- /dev/null +++ b/extensions/blocks/simple-payments/product-placeholder.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; + +/** + * Internal dependencies + */ +import './product-placeholder.scss'; +import paypalImage from './paypal-button.png'; +import paypalImage2x from './paypal-button-2x.png'; + +export default ( { + title = '', + content = '', + formattedPrice = '', + multiple = false, + featuredMediaUrl = null, + featuredMediaTitle = null, +} ) => ( + <div className="jetpack-simple-payments-wrapper"> + <div className="jetpack-simple-payments-product"> + { featuredMediaUrl && ( + <div className="jetpack-simple-payments-product-image"> + <figure className="jetpack-simple-payments-image"> + <img src={ featuredMediaUrl } alt={ featuredMediaTitle } /> + </figure> + </div> + ) } + <div className="jetpack-simple-payments-details"> + { title && ( + <div className="jetpack-simple-payments-title"> + <p>{ title }</p> + </div> + ) } + { content && ( + <div className="jetpack-simple-payments-description"> + <p>{ content }</p> + </div> + ) } + { formattedPrice && ( + <div className="jetpack-simple-payments-price"> + <p>{ formattedPrice }</p> + </div> + ) } + <div className="jetpack-simple-payments-purchase-box"> + { multiple && ( + <div className="jetpack-simple-payments-items"> + <input + className="jetpack-simple-payments-items-number" + readOnly + type="number" + value="1" + /> + </div> + ) } + <div className="jetpack-simple-payments-button"> + <img + alt={ __( 'Pay with PayPal' ) } + src={ paypalImage } + srcSet={ `${ paypalImage2x } 2x` } + /> + </div> + </div> + </div> + </div> + </div> +); diff --git a/extensions/blocks/simple-payments/product-placeholder.scss b/extensions/blocks/simple-payments/product-placeholder.scss new file mode 100644 index 0000000000000..ca99e1e0b3aee --- /dev/null +++ b/extensions/blocks/simple-payments/product-placeholder.scss @@ -0,0 +1,92 @@ + +.simple-payments__loading { + animation: simple-payments-loading 1600ms ease-in-out infinite; +} + +@keyframes simple-payments-loading { + 0% { + opacity: 0.5; + } + 50% { + opacity: 0.7; + } + 100% { + opacity: 0.5; + } +} + +.jetpack-simple-payments-wrapper { + margin-bottom: 1.5em; +} + +/* Higher specificity in order to reset paragraph style */ +body .jetpack-simple-payments-wrapper .jetpack-simple-payments-details p { + margin: 0 0 1.5em; + padding: 0; +} + +.jetpack-simple-payments-product { + display: flex; + flex-direction: column; +} + +.jetpack-simple-payments-product-image { + flex: 0 0 30%; + margin-bottom: 1.5em; +} + +.jetpack-simple-payments-image { + box-sizing: border-box; + min-width: 70px; + padding-top: 100%; + position: relative; +} + +.jetpack-simple-payments-image img { + border: 0; + border-radius: 0; + height: auto; + left: 50%; + margin: 0; + max-height: 100%; + max-width: 100%; + padding: 0; + position: absolute; + top: 50%; + transform: translate( -50%, -50% ); + width: auto; +} + +.jetpack-simple-payments-title p, +.jetpack-simple-payments-price p { + font-weight: bold; +} + +.jetpack-simple-payments-purchase-box { + align-items: flex-start; + display: flex; +} + +.jetpack-simple-payments-items { + flex: 0 0 auto; + margin-right: 10px; +} + +input[type='number'].jetpack-simple-payments-items-number { + background: var( --color-white ); + font-size: 16px; + line-height: 1; + max-width: 60px; + padding: 4px 8px; +} + +@media screen and ( min-width: 400px ) { + .jetpack-simple-payments-product { + flex-direction: row; + } + + .jetpack-simple-payments-product-image + .jetpack-simple-payments-details { + flex-basis: 70%; + padding-left: 1em; + } +} diff --git a/extensions/blocks/simple-payments/save.js b/extensions/blocks/simple-payments/save.js new file mode 100644 index 0000000000000..ed81e7a8f1b6d --- /dev/null +++ b/extensions/blocks/simple-payments/save.js @@ -0,0 +1,9 @@ +/** + * External dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function Save( { attributes } ) { + const { productId } = attributes; + return productId ? <RawHTML>{ `[simple-payment id="${ productId }"]` }</RawHTML> : null; +} diff --git a/extensions/blocks/simple-payments/utils.js b/extensions/blocks/simple-payments/utils.js new file mode 100644 index 0000000000000..c29e367baad3e --- /dev/null +++ b/extensions/blocks/simple-payments/utils.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { getCurrencyDefaults } from '@automattic/format-currency'; +import { trimEnd } from 'lodash'; + +/** + * Internal dependencies + */ +import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE } from './constants'; + +export const isValidSimplePaymentsProduct = product => + product.type === SIMPLE_PAYMENTS_PRODUCT_POST_TYPE && product.status === 'publish'; + +// based on https://stackoverflow.com/a/10454560/59752 +export const decimalPlaces = number => { + const match = ( '' + number ).match( /(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ ); + if ( ! match ) { + return 0; + } + return Math.max( 0, ( match[ 1 ] ? match[ 1 ].length : 0 ) - ( match[ 2 ] ? +match[ 2 ] : 0 ) ); +}; + +export const formatPrice = ( price, currency, withSymbol = true ) => { + const { precision, symbol } = getCurrencyDefaults( currency ); + const value = price.toFixed( precision ); + // Trim the dot at the end of symbol, e.g., 'kr.' becomes 'kr' + return withSymbol ? `${ value } ${ trimEnd( symbol, '.' ) }` : value; +}; diff --git a/extensions/blocks/slideshow/create-swiper.js b/extensions/blocks/slideshow/create-swiper.js new file mode 100644 index 0000000000000..72a54f56a93fe --- /dev/null +++ b/extensions/blocks/slideshow/create-swiper.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { mapValues, merge } from 'lodash'; + +/** + * Internal dependencies + */ +import './style.scss'; + +export default async function createSwiper( + container = '.swiper-container', + params = {}, + callbacks = {} +) { + const defaultParams = { + effect: 'slide', + grabCursor: true, + init: true, + initialSlide: 0, + navigation: { + nextEl: '.swiper-button-next', + prevEl: '.swiper-button-prev', + }, + pagination: { + bulletElement: 'button', + clickable: true, + el: '.swiper-pagination', + type: 'bullets', + }, + preventClicksPropagation: false /* Necessary for normal block interactions */, + releaseFormElements: false, + setWrapperSize: true, + touchStartPreventDefault: false, + on: mapValues( + callbacks, + callback => + function() { + callback( this ); + } + ), + }; + const [ { default: Swiper } ] = await Promise.all( [ + import( /* webpackChunkName: "swiper" */ 'swiper/dist/js/swiper.js' ), + import( /* webpackChunkName: "swiper" */ 'swiper/dist/css/swiper.css' ), + ] ); + return new Swiper( container, merge( {}, defaultParams, params ) ); +} diff --git a/extensions/blocks/slideshow/edit.js b/extensions/blocks/slideshow/edit.js new file mode 100644 index 0000000000000..0934cb7b5cfcc --- /dev/null +++ b/extensions/blocks/slideshow/edit.js @@ -0,0 +1,229 @@ +/** + * External dependencies + */ +import { __, _x } from '../../utils/i18n'; +import { isBlobURL } from '@wordpress/blob'; +import { compose } from '@wordpress/compose'; +import { withDispatch } from '@wordpress/data'; +import { Component, Fragment } from '@wordpress/element'; +import { + BlockControls, + MediaUpload, + MediaPlaceholder, + InspectorControls, + mediaUpload, +} from '@wordpress/editor'; + +import { + DropZone, + FormFileUpload, + IconButton, + PanelBody, + RangeControl, + SelectControl, + ToggleControl, + Toolbar, + withNotices, +} from '@wordpress/components'; +import { filter, pick } from 'lodash'; + +/** + * Internal dependencies + */ +import Slideshow from './slideshow'; +import './editor.scss'; + +const ALLOWED_MEDIA_TYPES = [ 'image' ]; + +const effectOptions = [ + { label: _x( 'Slide', 'Slideshow transition effect' ), value: 'slide' }, + { label: _x( 'Fade', 'Slideshow transition effect' ), value: 'fade' }, +]; + +export const pickRelevantMediaFiles = image => + pick( image, [ 'alt', 'id', 'link', 'url', 'caption' ] ); + +class SlideshowEdit extends Component { + constructor() { + super( ...arguments ); + this.state = { + selectedImage: null, + }; + } + onSelectImages = images => { + const { setAttributes } = this.props; + const mapped = images.map( image => pickRelevantMediaFiles( image ) ); + setAttributes( { + images: mapped, + } ); + }; + onRemoveImage = index => { + return () => { + const images = filter( this.props.attributes.images, ( img, i ) => index !== i ); + this.setState( { selectedImage: null } ); + this.props.setAttributes( { images } ); + }; + }; + addFiles = files => { + const currentImages = this.props.attributes.images || []; + const { lockPostSaving, unlockPostSaving, noticeOperations, setAttributes } = this.props; + const lockName = 'slideshowBlockLock'; + lockPostSaving( lockName ); + mediaUpload( { + allowedTypes: ALLOWED_MEDIA_TYPES, + filesList: files, + onFileChange: images => { + const imagesNormalized = images.map( image => pickRelevantMediaFiles( image ) ); + setAttributes( { + images: [ ...currentImages, ...imagesNormalized ], + } ); + if ( ! imagesNormalized.every( image => isBlobURL( image.url ) ) ) { + unlockPostSaving( lockName ); + } + }, + onError: noticeOperations.createErrorNotice, + } ); + }; + uploadFromFiles = event => this.addFiles( event.target.files ); + render() { + const { + attributes, + className, + isSelected, + noticeOperations, + noticeUI, + setAttributes, + } = this.props; + const { align, autoplay, delay, effect, images } = attributes; + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; + const controls = ( + <Fragment> + <InspectorControls> + <PanelBody title={ __( 'Autoplay' ) }> + <ToggleControl + label={ __( 'Autoplay' ) } + help={ __( 'Autoplay between slides' ) } + checked={ autoplay } + onChange={ value => { + setAttributes( { autoplay: value } ); + } } + /> + { autoplay && ( + <RangeControl + label={ __( 'Delay between transitions (in seconds)' ) } + value={ delay } + onChange={ value => { + setAttributes( { delay: value } ); + } } + min={ 1 } + max={ 5 } + /> + ) } + { autoplay && prefersReducedMotion && ( + <span> + { __( + 'The Reduce Motion accessibility option is selected, therefore autoplay will be disabled in this browser.' + ) } + </span> + ) } + </PanelBody> + <PanelBody title={ __( 'Effects' ) }> + <SelectControl + label={ __( 'Transition effect' ) } + value={ effect } + onChange={ value => { + setAttributes( { effect: value } ); + } } + options={ effectOptions } + /> + </PanelBody> + </InspectorControls> + <BlockControls> + { !! images.length && ( + <Toolbar> + <MediaUpload + onSelect={ this.onSelectImages } + allowedTypes={ ALLOWED_MEDIA_TYPES } + multiple + gallery + value={ images.map( img => img.id ) } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit Slideshow' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + ) } + </BlockControls> + </Fragment> + ); + + if ( images.length === 0 ) { + return ( + <Fragment> + { controls } + <MediaPlaceholder + icon="format-gallery" + className={ className } + labels={ { + title: __( 'Slideshow' ), + instructions: __( 'Drag images, upload new ones or select files from your library.' ), + } } + onSelect={ this.onSelectImages } + accept="image/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + multiple + notices={ noticeUI } + onError={ noticeOperations.createErrorNotice } + /> + </Fragment> + ); + } + return ( + <Fragment> + { controls } + { noticeUI } + <Slideshow + align={ align } + autoplay={ autoplay } + className={ className } + delay={ delay } + effect={ effect } + images={ images } + onError={ noticeOperations.createErrorNotice } + /> + <DropZone onFilesDrop={ this.addFiles } /> + { isSelected && ( + <div className="wp-block-jetpack-slideshow__add-item"> + <FormFileUpload + multiple + isLarge + className="wp-block-jetpack-slideshow__add-item-button" + onChange={ this.uploadFromFiles } + accept="image/*" + icon="insert" + > + { __( 'Upload an image' ) } + </FormFileUpload> + </div> + ) } + </Fragment> + ); + } +} +export default compose( + withDispatch( dispatch => { + const { lockPostSaving, unlockPostSaving } = dispatch( 'core/editor' ); + return { + lockPostSaving, + unlockPostSaving, + }; + } ), + withNotices +)( SlideshowEdit ); diff --git a/extensions/blocks/slideshow/editor.js b/extensions/blocks/slideshow/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/slideshow/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/slideshow/editor.scss b/extensions/blocks/slideshow/editor.scss new file mode 100644 index 0000000000000..b04e53eec9655 --- /dev/null +++ b/extensions/blocks/slideshow/editor.scss @@ -0,0 +1,43 @@ +.wp-block-jetpack-slideshow__add-item { + margin-top: 4px; + width: 100%; + + .components-form-file-upload, + .components-button.wp-block-jetpack-slideshow__add-item-button { + width: 100%; + height: 100%; + } + + .components-button.wp-block-jetpack-slideshow__add-item-button { + display: flex; + flex-direction: column; + justify-content: center; + box-shadow: none; + border: none; + border-radius: 0; + min-height: 100px; + + .dashicon { + margin-top: 10px; + } + + &:hover, + &:focus { + border: 1px solid #555d66; + } + } + +} + +.wp-block-jetpack-slideshow_slide { + .components-spinner { + position: absolute; + top: 50%; + left: 50%; + margin-top: -9px; + margin-left: -9px; + } + &.is-transient img { + opacity: 0.3; + } +} diff --git a/extensions/blocks/slideshow/index.js b/extensions/blocks/slideshow/index.js new file mode 100644 index 0000000000000..1844a9e5f07fb --- /dev/null +++ b/extensions/blocks/slideshow/index.js @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; +import { __ } from '../../utils/i18n'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import transforms from './transforms'; + +const icon = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path d="M0 0h24v24H0z" fill="none" /> + <Path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z" /> + </SVG> +); + +const attributes = { + align: { + default: 'center', + type: 'string', + }, + autoplay: { + type: 'boolean', + default: false, + }, + delay: { + type: 'number', + default: 3, + }, + images: { + type: 'array', + default: [], + source: 'query', + selector: '.swiper-slide', + query: { + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + caption: { + type: 'string', + source: 'html', + selector: 'figcaption', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + }, + }, + effect: { + type: 'string', + default: 'slide', + }, +}; + +export const name = 'slideshow'; + +export const settings = { + title: __( 'Slideshow' ), + category: 'jetpack', + keywords: [ __( 'image' ), __( 'gallery' ), __( 'slider' ) ], + description: __( 'Add an interactive slideshow.' ), + attributes, + supports: { + align: [ 'center', 'wide', 'full' ], + html: false, + }, + icon, + edit, + save, + transforms, +}; diff --git a/extensions/blocks/slideshow/save.js b/extensions/blocks/slideshow/save.js new file mode 100644 index 0000000000000..59879ded67abd --- /dev/null +++ b/extensions/blocks/slideshow/save.js @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import Slideshow from './slideshow'; + +export default ( { attributes: { align, autoplay, delay, effect, images }, className } ) => ( + <Slideshow + align={ align } + autoplay={ autoplay } + className={ className } + delay={ delay } + effect={ effect } + images={ images } + /> +); diff --git a/extensions/blocks/slideshow/slideshow.js b/extensions/blocks/slideshow/slideshow.js new file mode 100644 index 0000000000000..9d395df75c5c0 --- /dev/null +++ b/extensions/blocks/slideshow/slideshow.js @@ -0,0 +1,228 @@ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; +import ResizeObserver from 'resize-observer-polyfill'; +import classnames from 'classnames'; +import { Component, createRef } from '@wordpress/element'; +import { isBlobURL } from '@wordpress/blob'; +import { isEqual } from 'lodash'; +import { RichText } from '@wordpress/editor'; +import { Spinner } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import createSwiper from './create-swiper'; +import { + swiperApplyAria, + swiperInit, + swiperPaginationRender, + swiperResize, +} from './swiper-callbacks'; + +class Slideshow extends Component { + pendingRequestAnimationFrame = null; + resizeObserver = null; + static defaultProps = { + effect: 'slide', + }; + + constructor( props ) { + super( props ); + + this.slideshowRef = createRef(); + this.btnNextRef = createRef(); + this.btnPrevRef = createRef(); + this.paginationRef = createRef(); + } + + componentDidMount() { + const { onError } = this.props; + this.buildSwiper() + .then( swiper => { + this.swiperInstance = swiper; + this.initializeResizeObserver( swiper ); + } ) + .catch( () => { + onError( __( 'The Swiper library could not be loaded.' ) ); + } ); + } + + componentWillUnmount() { + this.clearResizeObserver(); + this.clearPendingRequestAnimationFrame(); + } + + componentDidUpdate( prevProps ) { + const { align, autoplay, delay, effect, images, onError } = this.props; + + /* A change in alignment or images only needs an update */ + if ( align !== prevProps.align || ! isEqual( images, prevProps.images ) ) { + this.swiperInstance && this.swiperInstance.update(); + } + /* A change in effect requires a full rebuild */ + if ( + effect !== prevProps.effect || + autoplay !== prevProps.autoplay || + delay !== prevProps.delay || + images !== prevProps.images + ) { + const realIndex = + images.length === prevProps.images.length + ? this.swiperInstance.realIndex + : prevProps.images.length; + this.swiperInstance && this.swiperInstance.destroy( true, true ); + this.buildSwiper( realIndex ) + .then( swiper => { + this.swiperInstance = swiper; + this.initializeResizeObserver( swiper ); + } ) + .catch( () => { + onError( __( 'The Swiper library could not be loaded.' ) ); + } ); + } + } + + initializeResizeObserver = swiper => { + this.clearResizeObserver(); + this.resizeObserver = new ResizeObserver( () => { + this.clearPendingRequestAnimationFrame(); + this.pendingRequestAnimationFrame = requestAnimationFrame( () => { + swiperResize( swiper ); + swiper.update(); + } ); + } ); + this.resizeObserver.observe( swiper.el ); + }; + + clearPendingRequestAnimationFrame = () => { + if ( this.pendingRequestAnimationFrame ) { + cancelAnimationFrame( this.pendingRequestAnimationFrame ); + this.pendingRequestAnimationFrame = null; + } + }; + + clearResizeObserver = () => { + if ( this.resizeObserver ) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + }; + + render() { + const { autoplay, className, delay, effect, images } = this.props; + // Note: React omits the data attribute if the value is null, but NOT if it is false. + // This is the reason for the unusual logic related to autoplay below. + /* eslint-disable jsx-a11y/anchor-is-valid */ + return ( + <div + className={ className } + data-autoplay={ autoplay || null } + data-delay={ autoplay ? delay : null } + data-effect={ effect } + > + <div + className="wp-block-jetpack-slideshow_container swiper-container" + ref={ this.slideshowRef } + > + <ul className="wp-block-jetpack-slideshow_swiper-wrappper swiper-wrapper"> + { images.map( ( { alt, caption, id, url } ) => ( + <li + className={ classnames( + 'wp-block-jetpack-slideshow_slide', + 'swiper-slide', + isBlobURL( url ) && 'is-transient' + ) } + key={ id } + > + <figure> + <img + alt={ alt } + className={ + `wp-block-jetpack-slideshow_image wp-image-${ id }` /* wp-image-${ id } makes WordPress add a srcset */ + } + data-id={ id } + src={ url } + /> + { isBlobURL( url ) && <Spinner /> } + { caption && ( + <RichText.Content + className="wp-block-jetpack-slideshow_caption gallery-caption" + tagName="figcaption" + value={ caption } + /> + ) } + </figure> + </li> + ) ) } + </ul> + <a + className="wp-block-jetpack-slideshow_button-prev swiper-button-prev swiper-button-white" + ref={ this.btnPrevRef } + role="button" + /> + <a + className="wp-block-jetpack-slideshow_button-next swiper-button-next swiper-button-white" + ref={ this.btnNextRef } + role="button" + /> + <a + aria-label="Pause Slideshow" + className="wp-block-jetpack-slideshow_button-pause" + role="button" + /> + <div + className="wp-block-jetpack-slideshow_pagination swiper-pagination swiper-pagination-white" + ref={ this.paginationRef } + /> + </div> + </div> + ); + /* eslint-enable jsx-a11y/anchor-is-valid */ + } + + prefersReducedMotion = () => { + return ( + typeof window !== 'undefined' && + window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches + ); + }; + + buildSwiper = ( initialSlide = 0 ) => + // Using refs instead of className-based selectors allows us to + // have multiple swipers on one page without collisions, and + // without needing to add IDs or the like. + createSwiper( + this.slideshowRef.current, + { + autoplay: + this.props.autoplay && ! this.prefersReducedMotion() + ? { + delay: this.props.delay * 1000, + disableOnInteraction: false, + } + : false, + effect: this.props.effect, + loop: true, + initialSlide, + navigation: { + nextEl: this.btnNextRef.current, + prevEl: this.btnPrevRef.current, + }, + pagination: { + clickable: true, + el: this.paginationRef.current, + type: 'bullets', + }, + }, + { + init: swiperInit, + imagesReady: swiperResize, + paginationRender: swiperPaginationRender, + transitionEnd: swiperApplyAria, + } + ); +} + +export default Slideshow; diff --git a/extensions/blocks/slideshow/slideshow.php b/extensions/blocks/slideshow/slideshow.php index 3004594137290..4f53ef8f10b32 100644 --- a/extensions/blocks/slideshow/slideshow.php +++ b/extensions/blocks/slideshow/slideshow.php @@ -25,6 +25,7 @@ function jetpack_slideshow_block_load_assets( $attr, $content ) { $dependencies = array( 'lodash', + 'wp-escape-html', 'wp-polyfill', ); diff --git a/extensions/blocks/slideshow/style.scss b/extensions/blocks/slideshow/style.scss new file mode 100644 index 0000000000000..701658fbb9ba1 --- /dev/null +++ b/extensions/blocks/slideshow/style.scss @@ -0,0 +1,162 @@ +.wp-block-jetpack-slideshow { + margin-bottom: 1.5em; + position: relative; + + .wp-block-jetpack-slideshow_container { + width: 100%; + overflow: hidden; + opacity: 0; + + &.wp-swiper-initialized { + opacity: 1; + } + + // High specifity to override theme styles + .wp-block-jetpack-slideshow_swiper-wrappper, + .wp-block-jetpack-slideshow_slide { + padding: 0; + margin: 0; + line-height: normal; + } + } + + .wp-block-jetpack-slideshow_slide { + background: rgba( 0, 0, 0, 0.1 ); + display: flex; + height: 100%; + width: 100%; + figure { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + margin: 0; + position: relative; + width: 100%; + } + } + + .swiper-container-fade .wp-block-jetpack-slideshow_slide { + background: var( --color-neutral-0 ); + } + + .wp-block-jetpack-slideshow_image { + display: block; + height: auto; + max-height: 100%; + max-width: 100%; + width: auto; + object-fit: contain; + } + + .wp-block-jetpack-slideshow_button-prev, + .wp-block-jetpack-slideshow_button-next, + .wp-block-jetpack-slideshow_button-pause { + background-color: rgba( 0, 0, 0, 0.5 ); + background-position: center; + background-repeat: no-repeat; + background-size: 24px; + border: 0; + border-radius: 4px; + box-shadow: none; + height: 48px; + margin: -24px 0 0; + padding: 0; + transition: background-color 250ms; + width: 48px; + + &:focus, + &:hover { + background-color: rgba( 0, 0, 0, 0.75 ); + } + + &:focus { + outline: thin dotted #fff; + outline-offset: -4px; + } + } + + &.swiper-container-rtl .swiper-button-prev.swiper-button-white, + &.swiper-container-rtl .wp-block-jetpack-slideshow_button-prev, + .swiper-button-next.swiper-button-white, + .wp-block-jetpack-slideshow_button-next { + background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M5.88 4.12L13.76 12l-7.88 7.88L8 22l10-10L8 2z' fill='white'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E" ); + } + + &.swiper-container-rtl .swiper-button-next.swiper-button-white, + &.swiper-container-rtl .wp-block-jetpack-slideshow_button-next, + .swiper-button-prev.swiper-button-white, + .wp-block-jetpack-slideshow_button-prev { + background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18 4.12L10.12 12 18 19.88 15.88 22l-10-10 10-10z' fill='white'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E" ); + } + + .wp-block-jetpack-slideshow_button-pause { + background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z' fill='white'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E" ); + display: none; + margin-top: 0; + position: absolute; + right: 10px; + top: 10px; + z-index: 1; + } + + .wp-block-jetpack-slideshow_autoplay-paused .wp-block-jetpack-slideshow_button-pause { + background-image: url( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M8 5v14l11-7z' fill='white'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E" ); + } + + &[data-autoplay='true'] .wp-block-jetpack-slideshow_button-pause { + display: block; + } + + .wp-block-jetpack-slideshow_caption.gallery-caption { + background-color: rgba( 0, 0, 0, 0.5 ); + box-sizing: border-box; + bottom: 0; + color: #fff; + cursor: text; + left: 0; + margin: 0 !important; + padding: 0.75em; + position: absolute; + right: 0; + text-align: initial; + z-index: 1; + a { + color: inherit; + } + } + + .wp-block-jetpack-slideshow_pagination.swiper-pagination-bullets { + bottom: 0; + line-height: 24px; + padding: 10px 0 2px; + position: relative; + + .swiper-pagination-bullet { + background: currentColor; + color: currentColor; + height: 16px; + opacity: 0.5; + transform: scale( 0.75 ); + transition: opacity 250ms, transform 250ms; + vertical-align: top; + width: 16px; + + &:focus, + &:hover { + opacity: 1; + } + + &:focus { + outline: thin dotted; + outline-offset: 0; + } + } + + .swiper-pagination-bullet-active { + background-color: currentColor; + opacity: 1; + transform: scale( 1 ); + } + } +} diff --git a/extensions/blocks/slideshow/swiper-callbacks.js b/extensions/blocks/slideshow/swiper-callbacks.js new file mode 100644 index 0000000000000..2410f234db4c1 --- /dev/null +++ b/extensions/blocks/slideshow/swiper-callbacks.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { escapeHTML } from '@wordpress/escape-html'; +import { forEach } from 'lodash'; + +const SIXTEEN_BY_NINE = 16 / 9; +const MAX_HEIGHT_PERCENT_OF_WINDOW_HEIGHT = 0.8; +const SANITY_MAX_HEIGHT = 600; +const PAUSE_CLASS = 'wp-block-jetpack-slideshow_autoplay-paused'; + +function swiperInit( swiper ) { + swiperResize( swiper ); + swiperApplyAria( swiper ); + swiper.el + .querySelector( '.wp-block-jetpack-slideshow_button-pause' ) + .addEventListener( 'click', function() { + // Handle destroyed Swiper instances + if ( ! swiper.el ) { + return; + } + if ( swiper.el.classList.contains( PAUSE_CLASS ) ) { + swiper.el.classList.remove( PAUSE_CLASS ); + swiper.autoplay.start(); + this.setAttribute( 'aria-label', 'Pause Slideshow' ); + } else { + swiper.el.classList.add( PAUSE_CLASS ); + swiper.autoplay.stop(); + this.setAttribute( 'aria-label', 'Play Slideshow' ); + } + } ); +} + +function swiperResize( swiper ) { + if ( ! swiper || ! swiper.el ) { + return; + } + const img = swiper.el.querySelector( '.swiper-slide[data-swiper-slide-index="0"] img' ); + if ( ! img ) { + return; + } + const aspectRatio = img.clientWidth / img.clientHeight; + const sanityAspectRatio = Math.max( Math.min( aspectRatio, SIXTEEN_BY_NINE ), 1 ); + const sanityHeight = + typeof window !== 'undefined' + ? window.innerHeight * MAX_HEIGHT_PERCENT_OF_WINDOW_HEIGHT + : SANITY_MAX_HEIGHT; + const swiperHeight = Math.min( swiper.width / sanityAspectRatio, sanityHeight ); + const wrapperHeight = `${ Math.floor( swiperHeight ) }px`; + const buttonTop = `${ Math.floor( swiperHeight / 2 ) }px`; + + swiper.el.classList.add( 'wp-swiper-initialized' ); + swiper.wrapperEl.style.height = wrapperHeight; + swiper.el.querySelector( '.wp-block-jetpack-slideshow_button-prev' ).style.top = buttonTop; + swiper.el.querySelector( '.wp-block-jetpack-slideshow_button-next' ).style.top = buttonTop; +} + +function announceCurrentSlide( swiper ) { + const currentSlide = swiper.slides[ swiper.activeIndex ]; + if ( ! currentSlide ) { + return; + } + const figcaption = currentSlide.getElementsByTagName( 'FIGCAPTION' )[ 0 ]; + const img = currentSlide.getElementsByTagName( 'IMG' )[ 0 ]; + if ( swiper.a11y.liveRegion ) { + swiper.a11y.liveRegion[ 0 ].innerHTML = figcaption + ? figcaption.innerHTML + : escapeHTML( img.alt ); + } +} + +function swiperApplyAria( swiper ) { + forEach( swiper.slides, ( slide, index ) => { + slide.setAttribute( 'aria-hidden', index === swiper.activeIndex ? 'false' : 'true' ); + if ( index === swiper.activeIndex ) { + slide.setAttribute( 'tabindex', '-1' ); + } else { + slide.removeAttribute( 'tabindex' ); + } + } ); + announceCurrentSlide( swiper ); +} + +function swiperPaginationRender( swiper ) { + forEach( swiper.pagination.bullets, bullet => { + bullet.addEventListener( 'click', () => { + const currentSlide = swiper.slides[ swiper.realIndex ]; + setTimeout( () => { + currentSlide.focus(); + }, 500 ); + } ); + } ); +} + +export { swiperApplyAria, swiperInit, swiperPaginationRender, swiperResize }; diff --git a/extensions/blocks/slideshow/transforms.js b/extensions/blocks/slideshow/transforms.js new file mode 100644 index 0000000000000..f0fba8cb1335e --- /dev/null +++ b/extensions/blocks/slideshow/transforms.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { filter } from 'lodash'; + +const transforms = { + from: [ + { + type: 'block', + blocks: [ 'core/gallery', 'jetpack/tiled-gallery' ], + transform: attributes => { + const validImages = filter( attributes.images, ( { id, url } ) => id && url ); + if ( validImages.length > 0 ) { + return createBlock( 'jetpack/slideshow', { + images: validImages.map( ( { id, url, alt, caption } ) => ( { + id, + url, + alt, + caption, + } ) ), + } ); + } + return createBlock( 'jetpack/slideshow' ); + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/gallery' ], + transform: ( { images } ) => createBlock( 'core/gallery', { images } ), + }, + { + type: 'block', + blocks: [ 'jetpack/tiled-gallery' ], + transform: ( { images } ) => createBlock( 'jetpack/tiled-gallery', { images }, [] ), + }, + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( { images } ) => { + if ( images.length > 0 ) { + return images.map( ( { id, url, alt, caption } ) => + createBlock( 'core/image', { id, url, alt, caption } ) + ); + } + return createBlock( 'core/image' ); + }, + }, + ], +}; + +export default transforms; diff --git a/extensions/blocks/slideshow/view.js b/extensions/blocks/slideshow/view.js new file mode 100644 index 0000000000000..6d8078972525f --- /dev/null +++ b/extensions/blocks/slideshow/view.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { forEach } from 'lodash'; +import ResizeObserver from 'resize-observer-polyfill'; + +/** + * Internal dependencies + */ +import createSwiper from './create-swiper'; +import { + swiperApplyAria, + swiperInit, + swiperPaginationRender, + swiperResize, +} from './swiper-callbacks'; + +typeof window !== 'undefined' && + window.addEventListener( 'load', function() { + const slideshowBlocks = document.getElementsByClassName( 'wp-block-jetpack-slideshow' ); + forEach( slideshowBlocks, slideshowBlock => { + const { autoplay, delay, effect } = slideshowBlock.dataset; + const prefersReducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; + const shouldAutoplay = autoplay && ! prefersReducedMotion; + const slideshowContainer = slideshowBlock.getElementsByClassName( 'swiper-container' )[ 0 ]; + let pendingRequestAnimationFrame = null; + createSwiper( + slideshowContainer, + { + autoplay: shouldAutoplay + ? { + delay: delay * 1000, + disableOnInteraction: false, + } + : false, + effect, + init: true, + initialSlide: 0, + loop: true, + keyboard: { + enabled: true, + onlyInViewport: true, + }, + }, + { + init: swiperInit, + imagesReady: swiperResize, + paginationRender: swiperPaginationRender, + transitionEnd: swiperApplyAria, + } + ) + .then( swiper => { + new ResizeObserver( () => { + if ( pendingRequestAnimationFrame ) { + cancelAnimationFrame( pendingRequestAnimationFrame ); + pendingRequestAnimationFrame = null; + } + pendingRequestAnimationFrame = requestAnimationFrame( () => { + swiperResize( swiper ); + swiper.update(); + } ); + } ).observe( swiper.el ); + } ) + .catch( () => { + slideshowBlock + .querySelector( '.wp-block-jetpack-slideshow_container' ) + .classList.add( 'wp-swiper-initialized' ); + } ); + } ); + } ); diff --git a/extensions/blocks/subscriptions/edit.js b/extensions/blocks/subscriptions/edit.js new file mode 100644 index 0000000000000..787a49a1d411e --- /dev/null +++ b/extensions/blocks/subscriptions/edit.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { TextControl, ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import SubmitButton from '../../utils/submit-button'; +import apiFetch from '@wordpress/api-fetch'; +import { sprintf, _n } from '@wordpress/i18n'; + +class SubscriptionEdit extends Component { + state = { + subscriberCountString: '', + }; + + componentDidMount() { + // Get the subscriber count so it is available right away if the user toggles the setting + this.get_subscriber_count(); + } + + render() { + const { attributes, className, isSelected, setAttributes } = this.props; + const { subscribePlaceholder, showSubscribersTotal } = attributes; + + if ( isSelected ) { + return ( + <div className={ className } role="form"> + <ToggleControl + label={ __( 'Show total subscribers' ) } + checked={ showSubscribersTotal } + onChange={ () => { + setAttributes( { showSubscribersTotal: ! showSubscribersTotal } ); + } } + /> + <TextControl + placeholder={ subscribePlaceholder } + disabled={ true } + onChange={ () => {} } + /> + <SubmitButton { ...this.props } /> + </div> + ); + } + + return ( + <div className={ className } role="form"> + { showSubscribersTotal && <p role="heading">{ this.state.subscriberCountString }</p> } + <TextControl placeholder={ subscribePlaceholder } /> + + <SubmitButton { ...this.props } /> + </div> + ); + } + + get_subscriber_count() { + apiFetch( { path: '/wpcom/v2/subscribers/count' } ).then( count => { + // Handle error condition + if ( ! count.hasOwnProperty( 'count' ) ) { + this.setState( { + subscriberCountString: __( 'Subscriber count unavailable' ), + } ); + } else { + this.setState( { + subscriberCountString: sprintf( + _n( 'Join %s other subscriber', 'Join %s other subscribers', count.count ), + count.count + ), + } ); + } + } ); + } + + onChangeSubmit( submitButtonText ) { + this.props.setAttributes( { submitButtonText } ); + } +} + +export default SubscriptionEdit; diff --git a/extensions/blocks/subscriptions/editor.js b/extensions/blocks/subscriptions/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/subscriptions/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/subscriptions/index.js b/extensions/blocks/subscriptions/index.js new file mode 100644 index 0000000000000..fbd27697d5756 --- /dev/null +++ b/extensions/blocks/subscriptions/index.js @@ -0,0 +1,76 @@ +/** + * Internal dependencies + */ +import edit from './edit'; +import save from './save'; +import { __ } from '../../utils/i18n'; +import renderMaterialIcon from '../../utils/render-material-icon'; +import { Path } from '@wordpress/components'; +import { isEmpty } from 'lodash'; +import { RawHTML } from '@wordpress/element'; + +export const name = 'subscriptions'; +export const settings = { + title: __( 'Subscription Form' ), + + description: ( + <p> + { __( + 'A form enabling readers to get notifications when new posts are published from this site.' + ) } + </p> + ), + icon: renderMaterialIcon( + <Path d="M23 16v2h-3v3h-2v-3h-3v-2h3v-3h2v3h3zM20 2v9h-4v3h-3v4H4c-1.1 0-2-.9-2-2V2h18zM8 13v-1H4v1h4zm3-3H4v1h7v-1zm0-2H4v1h7V8zm7-4H4v2h14V4z" /> + ), + category: 'jetpack', + + keywords: [ __( 'subscribe' ), __( 'join' ), __( 'follow' ) ], + + attributes: { + subscribePlaceholder: { type: 'string', default: __( 'Email Address' ) }, + subscribeButton: { type: 'string', default: __( 'Subscribe' ) }, + showSubscribersTotal: { type: 'boolean', default: false }, + submitButtonText: { + type: 'string', + default: __( 'Subscribe' ), + }, + customBackgroundButtonColor: { type: 'string' }, + customTextButtonColor: { type: 'string' }, + submitButtonClasses: { type: 'string' }, + }, + edit, + save, + deprecated: [ + { + attributes: { + subscribeButton: { type: 'string', default: __( 'Subscribe' ) }, + showSubscribersTotal: { type: 'boolean', default: false }, + }, + migrate: attr => { + return { + subscribeButton: '', + submitButtonText: attr.subscribeButton, + showSubscribersTotal: attr.showSubscribersTotal, + customBackgroundButtonColor: '', + customTextButtonColor: '', + submitButtonClasses: '', + }; + }, + + isEligible: attr => { + if ( ! isEmpty( attr.subscribeButton ) ) { + return false; + } + return true; + }, + save: function( { attributes } ) { + return ( + <RawHTML>{ `[jetpack_subscription_form show_subscribers_total="${ + attributes.showSubscribersTotal + }" show_only_email_and_button="true"]` }</RawHTML> + ); + }, + }, + ], +}; diff --git a/extensions/blocks/subscriptions/save.js b/extensions/blocks/subscriptions/save.js new file mode 100644 index 0000000000000..a7db7fe6288a8 --- /dev/null +++ b/extensions/blocks/subscriptions/save.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { RawHTML } from '@wordpress/element'; + +export default function Save( { attributes } ) { + const { + showSubscribersTotal, + submitButtonClasses, + customBackgroundButtonColor, + customTextButtonColor, + submitButtonText, + } = attributes; + return ( + <RawHTML>{ `[jetpack_subscription_form show_only_email_and_button="true" custom_background_button_color="${ customBackgroundButtonColor }" custom_text_button_color="${ customTextButtonColor }" submit_button_text="${ submitButtonText }" submit_button_classes="${ submitButtonClasses }" show_subscribers_total="${ showSubscribersTotal }" ]` }</RawHTML> + ); +} diff --git a/extensions/blocks/tiled-gallery/constants.js b/extensions/blocks/tiled-gallery/constants.js new file mode 100644 index 0000000000000..0df9073729066 --- /dev/null +++ b/extensions/blocks/tiled-gallery/constants.js @@ -0,0 +1,28 @@ +export const ALLOWED_MEDIA_TYPES = [ 'image' ]; +export const DEFAULT_GALLERY_WIDTH = 580; +export const GUTTER_WIDTH = 4; +export const MAX_COLUMNS = 20; +export const PHOTON_MAX_RESIZE = 2000; + +/** + * Layouts + */ +export const LAYOUT_CIRCLE = 'circle'; +export const LAYOUT_COLUMN = 'columns'; +export const LAYOUT_DEFAULT = 'rectangular'; +export const LAYOUT_SQUARE = 'square'; +export const LAYOUT_STYLES = [ + { + isDefault: true, + name: LAYOUT_DEFAULT, + }, + { + name: LAYOUT_CIRCLE, + }, + { + name: LAYOUT_SQUARE, + }, + { + name: LAYOUT_COLUMN, + }, +]; diff --git a/extensions/blocks/tiled-gallery/css-gram.scss b/extensions/blocks/tiled-gallery/css-gram.scss new file mode 100644 index 0000000000000..9fd2f49c52aa1 --- /dev/null +++ b/extensions/blocks/tiled-gallery/css-gram.scss @@ -0,0 +1,86 @@ +/** + * This code is based on CSS gram: + * https://github.com/una/CSSgram/tree/master + * + * Due to the packaging options available, the source has been duplicated and adapted here + * to best fit our specific needs. + */ + +/* From https://github.com/una/CSSgram/blob/0.1.12/source/scss/_shared.scss */ +@mixin pseudo-elem { + content: ''; + display: block; + height: 100%; + width: 100%; + top: 0; + left: 0; + position: absolute; + pointer-events: none; +} + +@mixin filter-base { + position: relative; + + img { + width: 100%; + z-index: 1; + } + + &::before { + @include pseudo-elem; + z-index: 2; + } + + &::after { + @include pseudo-elem; + z-index: 3; + } +} + +/** + * 1977 + * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/1977.scss + */ +@mixin _1977( $filters... ) { + @include filter-base; + filter: contrast( 1.1 ) brightness( 1.1 ) saturate( 1.3 ) $filters; + + &::after { + background: rgba( 243, 106, 188, 0.3 ); + mix-blend-mode: screen; + } + + @content; +} + +/* + * Clarendon + * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/clarendon.scss + */ +@mixin clarendon( $filters... ) { + @include filter-base; + filter: contrast( 1.2 ) saturate( 1.35 ) $filters; + + &::before { + background: rgba( 127, 187, 227, 0.2 ); + mix-blend-mode: overlay; + } + + @content; +} + +/** + * Gingham + * From https://github.com/una/CSSgram/blob/0.1.12/source/scss/gingham.scss + */ +@mixin gingham( $filters... ) { + @include filter-base; + filter: brightness( 1.05 ) hue-rotate( -10deg ) $filters; + + &::after { + background: rgb( 230, 230, 250 ); + mix-blend-mode: soft-light; + } + + @content; +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/constants.js b/extensions/blocks/tiled-gallery/deprecated/v1/constants.js new file mode 100644 index 0000000000000..55a451fccf618 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/constants.js @@ -0,0 +1,27 @@ +export const ALLOWED_MEDIA_TYPES = [ 'image' ]; +export const GUTTER_WIDTH = 4; +export const MAX_COLUMNS = 20; +export const PHOTON_MAX_RESIZE = 2000; + +/** + * Layouts + */ +export const LAYOUT_CIRCLE = 'circle'; +export const LAYOUT_COLUMN = 'columns'; +export const LAYOUT_DEFAULT = 'rectangular'; +export const LAYOUT_SQUARE = 'square'; +export const LAYOUT_STYLES = [ + { + isDefault: true, + name: LAYOUT_DEFAULT, + }, + { + name: LAYOUT_CIRCLE, + }, + { + name: LAYOUT_SQUARE, + }, + { + name: LAYOUT_COLUMN, + }, +]; diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/image.js b/extensions/blocks/tiled-gallery/deprecated/v1/image.js new file mode 100644 index 0000000000000..61d4a2cd05cef --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/image.js @@ -0,0 +1,51 @@ +/** + * External Dependencies + */ +import { isBlobURL } from '@wordpress/blob'; + +export default function GalleryImageSave( props ) { + const { + 'aria-label': ariaLabel, + alt, + // caption, + height, + id, + link, + linkTo, + origUrl, + url, + width, + } = props; + + if ( isBlobURL( origUrl ) ) { + return null; + } + + let href; + + switch ( linkTo ) { + case 'media': + href = url; + break; + case 'attachment': + href = link; + break; + } + + const img = ( + <img + alt={ alt } + aria-label={ ariaLabel } + data-height={ height } + data-id={ id } + data-link={ link } + data-url={ origUrl } + data-width={ width } + src={ url } + /> + ); + + return ( + <figure className="tiled-gallery__item">{ href ? <a href={ href }>{ img }</a> : img }</figure> + ); +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/index.js b/extensions/blocks/tiled-gallery/deprecated/v1/index.js new file mode 100644 index 0000000000000..69539d007cdef --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/index.js @@ -0,0 +1,81 @@ +/** + * Internal dependencies + */ +export { default as save } from './save'; +import { LAYOUT_DEFAULT } from './constants'; + +export const attributes = { + // Set default align + align: { + default: 'center', + type: 'string', + }, + // Set default className (used with block styles) + className: { + default: `is-style-${ LAYOUT_DEFAULT }`, + type: 'string', + }, + columns: { + type: 'number', + }, + ids: { + default: [], + type: 'array', + }, + images: { + type: 'array', + default: [], + source: 'query', + selector: '.tiled-gallery__item', + query: { + alt: { + attribute: 'alt', + default: '', + selector: 'img', + source: 'attribute', + }, + caption: { + selector: 'figcaption', + source: 'html', + type: 'string', + }, + height: { + attribute: 'data-height', + selector: 'img', + source: 'attribute', + type: 'number', + }, + id: { + attribute: 'data-id', + selector: 'img', + source: 'attribute', + }, + link: { + attribute: 'data-link', + selector: 'img', + source: 'attribute', + }, + url: { + attribute: 'data-url', + selector: 'img', + source: 'attribute', + }, + width: { + attribute: 'data-width', + selector: 'img', + source: 'attribute', + type: 'number', + }, + }, + }, + linkTo: { + default: 'none', + type: 'string', + }, +}; + +export const support = { + align: [ 'center', 'wide', 'full' ], + customClassName: false, + html: false, +}; diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js new file mode 100644 index 0000000000000..a3ed5cdf04cbb --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/column.js @@ -0,0 +1,3 @@ +export default function Column( { children } ) { + return <div className="tiled-gallery__col">{ children }</div>; +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js new file mode 100644 index 0000000000000..94fc61e4be980 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/gallery.js @@ -0,0 +1,7 @@ +export default function Gallery( { children, galleryRef } ) { + return ( + <div className="tiled-gallery__gallery" ref={ galleryRef }> + { children } + </div> + ); +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js new file mode 100644 index 0000000000000..0ae3f744860b0 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/index.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import photon from 'photon'; +import { Component } from '@wordpress/element'; +import { format as formatUrl, parse as parseUrl } from 'url'; +import { isBlobURL } from '@wordpress/blob'; +import { sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Image from '../image'; +import Mosaic from './mosaic'; +import Square from './square'; +import { __ } from '../../../../../utils/i18n'; +import { PHOTON_MAX_RESIZE } from '../constants'; + +export default class Layout extends Component { + photonize( { height, width, url } ) { + if ( ! url ) { + return; + } + + // Do not Photonize images that are still uploading or from localhost + if ( isBlobURL( url ) || /^https?:\/\/localhost/.test( url ) ) { + return url; + } + + // Drop query args, photon URLs can't handle them + // This should be the "raw" url, we'll add dimensions later + const cleanUrl = url.split( '?', 1 )[ 0 ]; + + const photonImplementation = isWpcomFilesUrl( url ) ? photonWpcomImage : photon; + + const { layoutStyle } = this.props; + + if ( isSquareishLayout( layoutStyle ) && width && height ) { + const size = Math.min( PHOTON_MAX_RESIZE, width, height ); + return photonImplementation( cleanUrl, { resize: `${ size },${ size }` } ); + } + return photonImplementation( cleanUrl ); + } + + // This is tricky: + // - We need to "photonize" to resize the images at appropriate dimensions + // - The resize will depend on the image size and the layout in some cases + // - Handlers need to be created by index so that the image changes can be applied correctly. + // This is because the images are stored in an array in the block attributes. + renderImage( img, i ) { + const { images, linkTo, selectedImage } = this.props; + + /* translators: %1$d is the order number of the image, %2$d is the total number of images. */ + const ariaLabel = sprintf( __( 'image %1$d of %2$d in gallery' ), i + 1, images.length ); + return ( + <Image + alt={ img.alt } + aria-label={ ariaLabel } + height={ img.height } + id={ img.id } + origUrl={ img.url } + isSelected={ selectedImage === i } + key={ i } + link={ img.link } + linkTo={ linkTo } + url={ this.photonize( img ) } + width={ img.width } + /> + ); + } + + render() { + const { align, children, className, columns, images, layoutStyle } = this.props; + + const LayoutRenderer = isSquareishLayout( layoutStyle ) ? Square : Mosaic; + + const renderedImages = this.props.images.map( this.renderImage, this ); + + return ( + <div className={ className }> + <LayoutRenderer + align={ align } + columns={ columns } + images={ images } + layoutStyle={ layoutStyle } + renderedImages={ renderedImages } + /> + { children } + </div> + ); + } +} + +function isSquareishLayout( layout ) { + return [ 'circle', 'square' ].includes( layout ); +} + +function isWpcomFilesUrl( url ) { + const { host } = parseUrl( url ); + return /\.files\.wordpress\.com$/.test( host ); +} + +/** + * Apply photon arguments to *.files.wordpress.com images + * + * This function largely duplicates the functionlity of the photon.js lib. + * This is necessary because we want to serve images from *.files.wordpress.com so that private + * WordPress.com sites can use this block which depends on a Photon-like image service. + * + * If we pass all images through Photon servers, some images are unreachable. *.files.wordpress.com + * is already photon-like so we can pass it the same parameters for image resizing. + * + * @param {string} url Image url + * @param {Object} opts Options to pass to photon + * + * @return {string} Url string with options applied + */ +function photonWpcomImage( url, opts = {} ) { + // Adhere to the same options API as the photon.js lib + const photonLibMappings = { + width: 'w', + height: 'h', + letterboxing: 'lb', + removeLetterboxing: 'ulb', + }; + + // Discard some param parts + const { auth, hash, port, query, search, ...urlParts } = parseUrl( url ); + + // Build query + // This reduction intentionally mutates the query as it is built internally. + urlParts.query = Object.keys( opts ).reduce( + ( q, key ) => + Object.assign( q, { + [ photonLibMappings.hasOwnProperty( key ) ? photonLibMappings[ key ] : key ]: opts[ key ], + } ), + {} + ); + + return formatUrl( urlParts ); +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js new file mode 100644 index 0000000000000..8c56b1641dd1e --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/index.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import ResizeObserver from 'resize-observer-polyfill'; + +/** + * Internal dependencies + */ +import Column from '../column'; +import Gallery from '../gallery'; +import Row from '../row'; +import { getGalleryRows, handleRowResize } from './resize'; +import { imagesToRatios, ratiosToColumns, ratiosToMosaicRows } from './ratios'; + +export default class Mosaic extends Component { + gallery = createRef(); + pendingRaf = null; + ro = null; // resizeObserver instance + + componentDidMount() { + this.observeResize(); + } + + componentWillUnmount() { + this.unobserveResize(); + } + + componentDidUpdate( prevProps ) { + if ( prevProps.images !== this.props.images || prevProps.align !== this.props.align ) { + this.triggerResize(); + } else if ( 'columns' === this.props.layoutStyle && prevProps.columns !== this.props.columns ) { + this.triggerResize(); + } + } + + handleGalleryResize = entries => { + if ( this.pendingRaf ) { + cancelAnimationFrame( this.pendingRaf ); + this.pendingRaf = null; + } + this.pendingRaf = requestAnimationFrame( () => { + for ( const { contentRect, target } of entries ) { + const { width } = contentRect; + getGalleryRows( target ).forEach( row => handleRowResize( row, width ) ); + } + } ); + }; + + triggerResize() { + if ( this.gallery.current ) { + this.handleGalleryResize( [ + { + target: this.gallery.current, + contentRect: { width: this.gallery.current.clientWidth }, + }, + ] ); + } + } + + observeResize() { + this.triggerResize(); + this.ro = new ResizeObserver( this.handleGalleryResize ); + if ( this.gallery.current ) { + this.ro.observe( this.gallery.current ); + } + } + + unobserveResize() { + if ( this.ro ) { + this.ro.disconnect(); + this.ro = null; + } + if ( this.pendingRaf ) { + cancelAnimationFrame( this.pendingRaf ); + this.pendingRaf = null; + } + } + + render() { + const { align, columns, images, layoutStyle, renderedImages } = this.props; + + const ratios = imagesToRatios( images ); + const rows = + 'columns' === layoutStyle + ? ratiosToColumns( ratios, columns ) + : ratiosToMosaicRows( ratios, { isWide: [ 'full', 'wide' ].includes( align ) } ); + + let cursor = 0; + return ( + <Gallery galleryRef={ this.gallery }> + { rows.map( ( row, rowIndex ) => ( + <Row key={ rowIndex }> + { row.map( ( colSize, colIndex ) => { + const columnImages = renderedImages.slice( cursor, cursor + colSize ); + cursor += colSize; + return <Column key={ colIndex }>{ columnImages }</Column>; + } ) } + </Row> + ) ) } + </Gallery> + ); + } +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js new file mode 100644 index 0000000000000..8accd552b710a --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/ratios.js @@ -0,0 +1,280 @@ +/** + * External dependencies + */ +import { + drop, + every, + isEqual, + map, + overEvery, + some, + sum, + take, + takeRight, + takeWhile, + zipWith, +} from 'lodash'; + +export function imagesToRatios( images ) { + return map( images, ratioFromImage ); +} + +export function ratioFromImage( { height, width } ) { + return height && width ? width / height : 1; +} + +/** + * Build three columns, each of which should contain approximately 1/3 of the total ratio + * + * @param {Array.<number>} ratios Ratios of images put into shape + * @param {number} columnCount Number of columns + * + * @return {Array.<Array.<number>>} Shape of rows and columns + */ +export function ratiosToColumns( ratios, columnCount ) { + // If we don't have more than 1 per column, just return a simple 1 ratio per column shape + if ( ratios.length <= columnCount ) { + return [ Array( ratios.length ).fill( 1 ) ]; + } + + const total = sum( ratios ); + const targetColRatio = total / columnCount; + + const row = []; + let toProcess = ratios; + let accumulatedRatio = 0; + + // We skip the last column in the loop and add rest later + for ( let i = 0; i < columnCount - 1; i++ ) { + const colSize = takeWhile( toProcess, ratio => { + const shouldTake = accumulatedRatio <= ( i + 1 ) * targetColRatio; + if ( shouldTake ) { + accumulatedRatio += ratio; + } + return shouldTake; + } ).length; + row.push( colSize ); + toProcess = drop( toProcess, colSize ); + } + + // Don't calculate last column, just add what's left + row.push( toProcess.length ); + + // A shape is an array of rows. Wrap our row in an array. + return [ row ]; +} + +/** + * These are partially applied functions. + * They rely on helper function (defined below) to create a function that expects to be passed ratios + * during processing. + * + * …FitsNextImages() functions should be passed ratios to be processed + * …IsNotRecent() functions should be passed the processed shapes + */ + +const reverseSymmetricRowIsNotRecent = isNotRecentShape( [ 2, 1, 2 ], 5 ); +const reverseSymmetricFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isPortrait, + isLandscape, + isLandscape, +] ); +const longSymmetricRowFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isLandscape, + isPortrait, + isLandscape, + isLandscape, + isLandscape, +] ); +const longSymmetricRowIsNotRecent = isNotRecentShape( [ 3, 1, 3 ], 5 ); +const symmetricRowFitsNextImages = checkNextRatios( [ + isPortrait, + isLandscape, + isLandscape, + isPortrait, +] ); +const symmetricRowIsNotRecent = isNotRecentShape( [ 1, 2, 1 ], 5 ); +const oneThreeFitsNextImages = checkNextRatios( [ + isPortrait, + isLandscape, + isLandscape, + isLandscape, +] ); +const oneThreeIsNotRecent = isNotRecentShape( [ 1, 3 ], 3 ); +const threeOneIsFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isLandscape, + isPortrait, +] ); +const threeOneIsNotRecent = isNotRecentShape( [ 3, 1 ], 3 ); +const oneTwoFitsNextImages = checkNextRatios( [ + lt( 1.6 ), + overEvery( gte( 0.9 ), lt( 2 ) ), + overEvery( gte( 0.9 ), lt( 2 ) ), +] ); +const oneTwoIsNotRecent = isNotRecentShape( [ 1, 2 ], 3 ); +const fiveIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1, 1 ], 1 ); +const fourIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1 ], 1 ); +const threeIsNotRecent = isNotRecentShape( [ 1, 1, 1 ], 3 ); +const twoOneFitsNextImages = checkNextRatios( [ + overEvery( gte( 0.9 ), lt( 2 ) ), + overEvery( gte( 0.9 ), lt( 2 ) ), + lt( 1.6 ), +] ); +const twoOneIsNotRecent = isNotRecentShape( [ 2, 1 ], 3 ); +const panoramicFitsNextImages = checkNextRatios( [ isPanoramic ] ); + +export function ratiosToMosaicRows( ratios, { isWide } = {} ) { + // This function will recursively process the input until it is consumed + const go = ( processed, toProcess ) => { + if ( ! toProcess.length ) { + return processed; + } + + let next; + + if ( + /* Reverse_Symmetric_Row */ + toProcess.length > 15 && + reverseSymmetricFitsNextImages( toProcess ) && + reverseSymmetricRowIsNotRecent( processed ) + ) { + next = [ 2, 1, 2 ]; + } else if ( + /* Long_Symmetric_Row */ + toProcess.length > 15 && + longSymmetricRowFitsNextImages( toProcess ) && + longSymmetricRowIsNotRecent( processed ) + ) { + next = [ 3, 1, 3 ]; + } else if ( + /* Symmetric_Row */ + toProcess.length !== 5 && + symmetricRowFitsNextImages( toProcess ) && + symmetricRowIsNotRecent( processed ) + ) { + next = [ 1, 2, 1 ]; + } else if ( + /* One_Three */ + oneThreeFitsNextImages( toProcess ) && + oneThreeIsNotRecent( processed ) + ) { + next = [ 1, 3 ]; + } else if ( + /* Three_One */ + threeOneIsFitsNextImages( toProcess ) && + threeOneIsNotRecent( processed ) + ) { + next = [ 3, 1 ]; + } else if ( + /* One_Two */ + oneTwoFitsNextImages( toProcess ) && + oneTwoIsNotRecent( processed ) + ) { + next = [ 1, 2 ]; + } else if ( + /* Five */ + isWide && + ( toProcess.length === 5 || ( toProcess.length !== 10 && toProcess.length > 6 ) ) && + fiveIsNotRecent( processed ) && + sum( take( toProcess, 5 ) ) < 5 + ) { + next = [ 1, 1, 1, 1, 1 ]; + } else if ( + /* Four */ + isFourValidCandidate( processed, toProcess ) + ) { + next = [ 1, 1, 1, 1 ]; + } else if ( + /* Three */ + isThreeValidCandidate( processed, toProcess, isWide ) + ) { + next = [ 1, 1, 1 ]; + } else if ( + /* Two_One */ + twoOneFitsNextImages( toProcess ) && + twoOneIsNotRecent( processed ) + ) { + next = [ 2, 1 ]; + } else if ( /* Panoramic */ panoramicFitsNextImages( toProcess ) ) { + next = [ 1 ]; + } else if ( /* One_One */ toProcess.length > 3 ) { + next = [ 1, 1 ]; + } else { + // Everything left + next = Array( toProcess.length ).fill( 1 ); + } + + // Add row + const nextProcessed = processed.concat( [ next ] ); + + // Trim consumed images from next processing step + const consumedImages = sum( next ); + const nextToProcess = toProcess.slice( consumedImages ); + + return go( nextProcessed, nextToProcess ); + }; + return go( [], ratios ); +} + +function isThreeValidCandidate( processed, toProcess, isWide ) { + const ratio = sum( take( toProcess, 3 ) ); + return ( + toProcess.length >= 3 && + toProcess.length !== 4 && + toProcess.length !== 6 && + threeIsNotRecent( processed ) && + ( ratio < 2.5 || + ( ratio < 5 && + /* nextAreSymettric */ + ( toProcess.length >= 3 && + /* @FIXME floating point equality?? */ toProcess[ 0 ] === toProcess[ 2 ] ) ) || + isWide ) + ); +} + +function isFourValidCandidate( processed, toProcess ) { + const ratio = sum( take( toProcess, 4 ) ); + return ( + ( fourIsNotRecent( processed ) && ( ratio < 3.5 && toProcess.length > 5 ) ) || + ( ratio < 7 && toProcess.length === 4 ) + ); +} + +function isNotRecentShape( shape, numRecents ) { + return recents => + ! some( takeRight( recents, numRecents ), recentShape => isEqual( recentShape, shape ) ); +} + +function checkNextRatios( shape ) { + return ratios => + ratios.length >= shape.length && + every( zipWith( shape, ratios.slice( 0, shape.length ), ( f, r ) => f( r ) ) ); +} + +function isLandscape( ratio ) { + return ratio >= 1 && ratio < 2; +} + +function isPortrait( ratio ) { + return ratio < 1; +} + +function isPanoramic( ratio ) { + return ratio >= 2; +} + +// >= +function gte( n ) { + return m => m >= n; +} + +// < +function lt( n ) { + return m => m < n; +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js new file mode 100644 index 0000000000000..022729c8bac72 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/mosaic/resize.js @@ -0,0 +1,107 @@ +/** + * Internal dependencies + */ +import { GUTTER_WIDTH } from '../../constants'; + +/** + * Distribute a difference across ns so that their sum matches the target + * + * @param {Array<number>} parts Array of numbers to fit + * @param {number} target Number that sum should match + * @return {Array<number>} Adjusted parts + */ +function adjustFit( parts, target ) { + const diff = target - parts.reduce( ( sum, n ) => sum + n, 0 ); + const partialDiff = diff / parts.length; + return parts.map( p => p + partialDiff ); +} + +export function handleRowResize( row, width ) { + applyRowRatio( row, getRowRatio( row ), width ); +} + +function getRowRatio( row ) { + const result = getRowCols( row ) + .map( getColumnRatio ) + .reduce( + ( [ ratioA, weightedRatioA ], [ ratioB, weightedRatioB ] ) => { + return [ ratioA + ratioB, weightedRatioA + weightedRatioB ]; + }, + [ 0, 0 ] + ); + return result; +} + +export function getGalleryRows( gallery ) { + return Array.from( gallery.querySelectorAll( '.tiled-gallery__row' ) ); +} + +function getRowCols( row ) { + return Array.from( row.querySelectorAll( '.tiled-gallery__col' ) ); +} + +function getColImgs( col ) { + return Array.from( + col.querySelectorAll( '.tiled-gallery__item > img, .tiled-gallery__item > a > img' ) + ); +} + +function getColumnRatio( col ) { + const imgs = getColImgs( col ); + const imgCount = imgs.length; + const ratio = + 1 / + imgs.map( getImageRatio ).reduce( ( partialColRatio, imgRatio ) => { + return partialColRatio + 1 / imgRatio; + }, 0 ); + const result = [ ratio, ratio * imgCount || 1 ]; + return result; +} + +function getImageRatio( img ) { + const w = parseInt( img.dataset.width, 10 ); + const h = parseInt( img.dataset.height, 10 ); + const result = w && ! Number.isNaN( w ) && h && ! Number.isNaN( h ) ? w / h : 1; + return result; +} + +function applyRowRatio( row, [ ratio, weightedRatio ], width ) { + const rawHeight = + ( 1 / ratio ) * ( width - GUTTER_WIDTH * ( row.childElementCount - 1 ) - weightedRatio ); + + applyColRatio( row, { + rawHeight, + rowWidth: width - GUTTER_WIDTH * ( row.childElementCount - 1 ), + } ); +} + +function applyColRatio( row, { rawHeight, rowWidth } ) { + const cols = getRowCols( row ); + + const colWidths = cols.map( + col => ( rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ) ) * getColumnRatio( col )[ 0 ] + ); + + const adjustedWidths = adjustFit( colWidths, rowWidth ); + + cols.forEach( ( col, i ) => { + const rawWidth = colWidths[ i ]; + const width = adjustedWidths[ i ]; + applyImgRatio( col, { + colHeight: rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ), + width, + rawWidth, + } ); + } ); +} + +function applyImgRatio( col, { colHeight, width, rawWidth } ) { + const imgHeights = getColImgs( col ).map( img => rawWidth / getImageRatio( img ) ); + const adjustedHeights = adjustFit( imgHeights, colHeight ); + + // Set size of col children, not the <img /> element + Array.from( col.children ).forEach( ( item, i ) => { + const height = adjustedHeights[ i ]; + item.setAttribute( 'style', `height:${ height }px;width:${ width }px;` ); + } ); +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js new file mode 100644 index 0000000000000..200a58c2e3acf --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/row.js @@ -0,0 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +export default function Row( { children, className } ) { + return <div className={ classnames( 'tiled-gallery__row', className ) }>{ children }</div>; +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js b/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js new file mode 100644 index 0000000000000..2a1ab888b1916 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/layout/square.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { chunk, drop, take } from 'lodash'; + +/** + * Internal dependencies + */ +import Row from './row'; +import Column from './column'; +import Gallery from './gallery'; +import { MAX_COLUMNS } from '../constants'; + +export default function Square( { columns, renderedImages } ) { + const columnCount = Math.min( MAX_COLUMNS, columns ); + + const remainder = renderedImages.length % columnCount; + + return ( + <Gallery> + { [ + ...( remainder ? [ take( renderedImages, remainder ) ] : [] ), + ...chunk( drop( renderedImages, remainder ), columnCount ), + ].map( ( imagesInRow, rowIndex ) => ( + <Row key={ rowIndex } className={ `columns-${ imagesInRow.length }` }> + { imagesInRow.map( ( image, colIndex ) => ( + <Column key={ colIndex }>{ image }</Column> + ) ) } + </Row> + ) ) } + </Gallery> + ); +} diff --git a/extensions/blocks/tiled-gallery/deprecated/v1/save.js b/extensions/blocks/tiled-gallery/deprecated/v1/save.js new file mode 100644 index 0000000000000..1a5b0b66eff79 --- /dev/null +++ b/extensions/blocks/tiled-gallery/deprecated/v1/save.js @@ -0,0 +1,31 @@ +/** + * Internal dependencies + */ +import Layout from './layout'; +import { getActiveStyleName } from '../../../../utils'; +import { LAYOUT_STYLES } from './constants'; + +export function defaultColumnsNumber( attributes ) { + return Math.min( 3, attributes.images.length ); +} + +export default function TiledGallerySave( { attributes } ) { + const { images } = attributes; + + if ( ! images.length ) { + return null; + } + + const { align, className, columns = defaultColumnsNumber( attributes ), linkTo } = attributes; + + return ( + <Layout + align={ align } + className={ className } + columns={ columns } + images={ images } + layoutStyle={ getActiveStyleName( LAYOUT_STYLES, className ) } + linkTo={ linkTo } + /> + ); +} diff --git a/extensions/blocks/tiled-gallery/edit.js b/extensions/blocks/tiled-gallery/edit.js new file mode 100644 index 0000000000000..17524c6d20235 --- /dev/null +++ b/extensions/blocks/tiled-gallery/edit.js @@ -0,0 +1,293 @@ +/** + * External Dependencies + */ +import { Component, Fragment } from '@wordpress/element'; +import { filter, get, pick } from 'lodash'; +import { + BlockControls, + InspectorControls, + MediaPlaceholder, + MediaUpload, + mediaUpload, +} from '@wordpress/editor'; +import { + DropZone, + FormFileUpload, + IconButton, + PanelBody, + RangeControl, + SelectControl, + Toolbar, + withNotices, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import FilterToolbar from './filter-toolbar'; +import Layout from './layout'; +import { __ } from '../../utils/i18n'; +import { ALLOWED_MEDIA_TYPES, LAYOUT_STYLES, MAX_COLUMNS } from './constants'; +import { getActiveStyleName } from '../../utils'; +import { icon } from '.'; + +const linkOptions = [ + { value: 'attachment', label: __( 'Attachment Page' ) }, + { value: 'media', label: __( 'Media File' ) }, + { value: 'none', label: __( 'None' ) }, +]; + +// @TODO keep here or move to ./layout ? +function layoutSupportsColumns( layout ) { + return [ 'columns', 'circle', 'square' ].includes( layout ); +} + +export function defaultColumnsNumber( attributes ) { + return Math.min( 3, attributes.images.length ); +} + +export const pickRelevantMediaFiles = image => { + const imageProps = pick( image, [ + [ 'alt' ], + [ 'id' ], + [ 'link' ], + /* @TODO Captions disabled [ 'caption' ], */ + ] ); + imageProps.url = + get( image, [ 'sizes', 'large', 'url' ] ) || + get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || + image.url; + return imageProps; +}; + +class TiledGalleryEdit extends Component { + state = { + selectedImage: null, + }; + + static getDerivedStateFromProps( props, state ) { + // Deselect images when deselecting the block + if ( ! props.isSelected && null !== state.selectedImage ) { + return { selectedImage: null }; + } + return null; + } + + setAttributes( attributes ) { + if ( attributes.ids ) { + throw new Error( + 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes' + ); + } + + if ( attributes.images ) { + attributes = { + ...attributes, + ids: attributes.images.map( ( { id } ) => parseInt( id, 10 ) ), + }; + } + + this.props.setAttributes( attributes ); + } + + addFiles = files => { + const currentImages = this.props.attributes.images || []; + const { noticeOperations } = this.props; + mediaUpload( { + allowedTypes: ALLOWED_MEDIA_TYPES, + filesList: files, + onFileChange: images => { + const imagesNormalized = images.map( image => pickRelevantMediaFiles( image ) ); + this.setAttributes( { images: currentImages.concat( imagesNormalized ) } ); + }, + onError: noticeOperations.createErrorNotice, + } ); + }; + + onRemoveImage = index => () => { + const images = filter( this.props.attributes.images, ( img, i ) => index !== i ); + const { columns } = this.props.attributes; + this.setState( { + selectedImage: null, + } ); + this.setAttributes( { + images, + columns: columns ? Math.min( images.length, columns ) : columns, + } ); + }; + + onSelectImage = index => () => { + if ( this.state.selectedImage !== index ) { + this.setState( { + selectedImage: index, + } ); + } + }; + + onSelectImages = images => { + const { columns } = this.props.attributes; + this.setAttributes( { + columns: columns ? Math.min( images.length, columns ) : columns, + images: images.map( image => pickRelevantMediaFiles( image ) ), + } ); + }; + + setColumnsNumber = value => this.setAttributes( { columns: value } ); + + setImageAttributes = index => attributes => { + const { + attributes: { images }, + } = this.props; + if ( ! images[ index ] ) { + return; + } + this.setAttributes( { + images: [ + ...images.slice( 0, index ), + { ...images[ index ], ...attributes }, + ...images.slice( index + 1 ), + ], + } ); + }; + + setLinkTo = value => this.setAttributes( { linkTo: value } ); + + uploadFromFiles = event => this.addFiles( event.target.files ); + + render() { + const { selectedImage } = this.state; + const { + attributes, + isSelected, + className, + noticeOperations, + noticeUI, + setAttributes, + } = this.props; + const { + align, + columns = defaultColumnsNumber( attributes ), + imageFilter, + images, + linkTo, + } = attributes; + + const dropZone = <DropZone onFilesDrop={ this.addFiles } />; + + const controls = ( + <BlockControls> + { !! images.length && ( + <Fragment> + <Toolbar> + <MediaUpload + onSelect={ this.onSelectImages } + allowedTypes={ ALLOWED_MEDIA_TYPES } + multiple + gallery + value={ images.map( img => img.id ) } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit Gallery' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + <FilterToolbar + value={ imageFilter } + onChange={ value => { + setAttributes( { imageFilter: value } ); + this.setState( { selectedImage: null } ); + } } + /> + </Fragment> + ) } + </BlockControls> + ); + + if ( images.length === 0 ) { + return ( + <Fragment> + { controls } + <MediaPlaceholder + icon={ <div className="tiled-gallery__media-placeholder-icon">{ icon }</div> } + className={ className } + labels={ { + title: __( 'Tiled Gallery' ), + name: __( 'images' ), + } } + onSelect={ this.onSelectImages } + accept="image/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + multiple + notices={ noticeUI } + onError={ noticeOperations.createErrorNotice } + /> + </Fragment> + ); + } + + const layoutStyle = getActiveStyleName( LAYOUT_STYLES, attributes.className ); + + return ( + <Fragment> + { controls } + <InspectorControls> + <PanelBody title={ __( 'Tiled Gallery settings' ) }> + { layoutSupportsColumns( layoutStyle ) && images.length > 1 && ( + <RangeControl + label={ __( 'Columns' ) } + value={ columns } + onChange={ this.setColumnsNumber } + min={ 1 } + max={ Math.min( MAX_COLUMNS, images.length ) } + /> + ) } + <SelectControl + label={ __( 'Link To' ) } + value={ linkTo } + onChange={ this.setLinkTo } + options={ linkOptions } + /> + </PanelBody> + </InspectorControls> + + { noticeUI } + + <Layout + align={ align } + className={ className } + columns={ columns } + imageFilter={ imageFilter } + images={ images } + layoutStyle={ layoutStyle } + linkTo={ linkTo } + onRemoveImage={ this.onRemoveImage } + onSelectImage={ this.onSelectImage } + selectedImage={ isSelected ? selectedImage : null } + setImageAttributes={ this.setImageAttributes } + > + { dropZone } + { isSelected && ( + <div className="tiled-gallery__add-item"> + <FormFileUpload + multiple + isLarge + className="tiled-gallery__add-item-button" + onChange={ this.uploadFromFiles } + accept="image/*" + icon="insert" + > + { __( 'Upload an image' ) } + </FormFileUpload> + </div> + ) } + </Layout> + </Fragment> + ); + } +} + +export default withNotices( TiledGalleryEdit ); diff --git a/extensions/blocks/tiled-gallery/editor.js b/extensions/blocks/tiled-gallery/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/tiled-gallery/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/tiled-gallery/editor.scss b/extensions/blocks/tiled-gallery/editor.scss new file mode 100644 index 0000000000000..4d7c9f5597dd7 --- /dev/null +++ b/extensions/blocks/tiled-gallery/editor.scss @@ -0,0 +1,183 @@ +@import './view.scss'; +@import './variables.scss'; + +// inspired by from assets/shared/_animations loading-fade +@keyframes tiled-gallery-img-placeholder { + 0% { + background-color: var( --color-neutral-0 ); + } + 50% { + background-color: rgba( var( --color-neutral-0-rgb ), 0.5 ); + } + 100% { + background-color: var( --color-neutral-0 ); + } +} + +.wp-block-jetpack-tiled-gallery { + // Ensure that selected image outlines are visibile + padding-left: 4px; + padding-right: 4px; + + .tiled-gallery__item { + // Hide the focus outline that otherwise briefly appears when selecting a block. + > img:focus { + outline: none; + } + + > img { + // Inspired by Calypso's placeholder mixin + animation: tiled-gallery-img-placeholder 1.6s ease-in-out infinite; + } + + &.is-selected { + outline: 4px solid $tiled-gallery-selection; + + // Disable filters when selected + filter: none; + &::before, + &::after { + content: none; + } + } + + &.is-transient img { + opacity: 0.3; + } + + /* @TODO Caption has been commented out */ + // .editor-rich-text { + // position: absolute; + // bottom: 0; + // width: 100%; + // max-height: 100%; + // overflow-y: auto; + // } + + // .editor-rich-text figcaption:not( [data-is-placeholder-visible='true'] ) { + // position: relative; + // overflow: hidden; + // color: var( --color-white ); + // } + + // &.is-selected .editor-rich-text { + // // IE calculates this incorrectly, so leave it to modern browsers. + // @supports ( position: sticky ) { + // right: 0; + // left: 0; + // margin-top: -4px; + // } + + // // Override negative margins so this toolbar isn't hidden by overflow. Overflow is needed for long captions. + // .editor-rich-text__inline-toolbar { + // top: 0; + // } + + // // Make extra space for the inline toolbar. + // .editor-rich-text__tinymce { + // padding-top: 48px; + // } + // } + } + + // Circle layout doesn't support captions + // @TODO handle this in the component + /* @TODO Caption has been commented out */ + // &.is-style-circle .tiled-gallery__item .editor-rich-text { + // display: none; + // } + + .tiled-gallery__add-item { + margin-top: $tiled-gallery-gutter; + width: 100%; + + .components-form-file-upload, + .components-button.tiled-gallery__add-item-button { + width: 100%; + height: 100%; + } + + .components-button.tiled-gallery__add-item-button { + display: flex; + flex-direction: column; + justify-content: center; + box-shadow: none; + border: none; + border-radius: 0; + min-height: 100px; + + .dashicon { + margin-top: 10px; + } + + &:hover, + &:focus { + border: $tiled-gallery-add-item-border-width solid $tiled-gallery-add-item-border-color; + } + } + } + + .tiled-gallery__item__inline-menu { + background-color: $tiled-gallery-selection; + display: inline-flex; + padding: 0 0 2px 2px; + position: absolute; + right: 0; + top: 0; + + .components-button { + color: var( --color-white ); + &:hover, + &:focus { + color: var( --color-white ); + } + } + } + + .tiled-gallery__item__remove { + padding: 0; + } + + .tiled-gallery__item .components-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate( -50%, -50% ); + } + + // Hide captions and upload buttons in style picker preview + .editor-block-preview__content & { + /* @TODO Caption has been commented out */ + // figcaption, + .editor-media-placeholder { + display: none; + } + } + + // Matches with `.dashicon` in `MediaPlaceholder` component + .tiled-gallery__media-placeholder-icon { + height: 20px; + margin-right: 1ch; // stylelint-disable-line unit-whitelist + width: 20px; + } +} + +.tiled-gallery__filter-picker-menu { + $active-item-outline-width: 2px; + + // @TODO replace with Gutenberg variables + $dark-gray-500: #555d66; + $dark-gray-900: #191e23; + + padding: 7px; + + // Leave space between elements for active state styling + .components-menu-item__button + .components-menu-item__button { + margin-top: $active-item-outline-width; + } + + .components-menu-item__button.is-active { + color: $dark-gray-900; + box-shadow: 0 0 0 $active-item-outline-width $dark-gray-500 !important; + } +} diff --git a/extensions/blocks/tiled-gallery/filter-toolbar.js b/extensions/blocks/tiled-gallery/filter-toolbar.js new file mode 100644 index 0000000000000..7938cf997e8fd --- /dev/null +++ b/extensions/blocks/tiled-gallery/filter-toolbar.js @@ -0,0 +1,140 @@ +/** + * External Dependencies + */ +import { Dropdown, MenuItem, NavigableMenu, Path, SVG, Toolbar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '../../utils/i18n'; + +const availableFilters = [ + { + icon: ( + /* No filter */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" /> + </SVG> + ), + title: _x( 'Original', 'image style' ), + value: undefined, + }, + { + icon: ( + /* 1 */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm11 10h2V5h-4v2h2v8zm7-14H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" /> + </SVG> + ), + title: _x( 'Black and White', 'image style' ), + value: 'black-and-white', + }, + { + icon: ( + /* 2 */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zm-4-4h-4v-2h2c1.1 0 2-.89 2-2V7c0-1.11-.9-2-2-2h-4v2h4v2h-2c-1.1 0-2 .89-2 2v4h6v-2z" /> + </SVG> + ), + title: _x( 'Sepia', 'image style' ), + value: 'sepia', + }, + { + icon: ( + /* 3 */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M21 1H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zM3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm14 8v-1.5c0-.83-.67-1.5-1.5-1.5.83 0 1.5-.67 1.5-1.5V7c0-1.11-.9-2-2-2h-4v2h4v2h-2v2h2v2h-4v2h4c1.1 0 2-.89 2-2z" /> + </SVG> + ), + title: '1977', + value: '1977', + }, + { + icon: ( + /* 4 */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm12 10h2V5h-2v4h-2V5h-2v6h4v4zm6-14H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" /> + </SVG> + ), + title: _x( 'Clarendon', 'image style' ), + value: 'clarendon', + }, + { + icon: ( + /* 5 */ + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0z" /> + <Path d="M21 1H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14zM3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm14 8v-2c0-1.11-.9-2-2-2h-2V7h4V5h-6v6h4v2h-4v2h4c1.1 0 2-.89 2-2z" /> + </SVG> + ), + title: _x( 'Gingham', 'image style' ), + value: 'gingham', + }, +]; + +const label = __( 'Pick an image filter' ); + +export default function FilterToolbar( { value, onChange } ) { + return ( + <Dropdown + position="bottom right" + className="editor-block-switcher" + contentClassName="editor-block-switcher__popover" + renderToggle={ ( { onToggle, isOpen } ) => { + return ( + <Toolbar + controls={ [ + { + onClick: onToggle, + extraProps: { + 'aria-haspopup': 'true', + 'aria-expanded': isOpen, + }, + title: label, + tooltip: label, + icon: ( + <SVG + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + > + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M19 10v9H4.98V5h9V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-9h-2zm-2.94-2.06L17 10l.94-2.06L20 7l-2.06-.94L17 4l-.94 2.06L14 7zM12 8l-1.25 2.75L8 12l2.75 1.25L12 16l1.25-2.75L16 12l-2.75-1.25z" /> + </SVG> + ), + }, + ] } + /> + ); + } } + renderContent={ ( { onClose } ) => { + const applyOrUnset = nextValue => () => { + onChange( value === nextValue ? undefined : nextValue ); + onClose(); + }; + return ( + <NavigableMenu className="tiled-gallery__filter-picker-menu"> + { availableFilters.map( ( { icon, title, value: filterValue } ) => ( + <MenuItem + className={ value === filterValue ? 'is-active' : undefined } + icon={ icon } + isSelected={ value === filterValue } + key={ filterValue || 'original' } + onClick={ applyOrUnset( filterValue ) } + role="menuitemcheckbox" + > + { title } + </MenuItem> + ) ) } + </NavigableMenu> + ); + } } + /> + ); +} diff --git a/extensions/blocks/tiled-gallery/gallery-image/edit.js b/extensions/blocks/tiled-gallery/gallery-image/edit.js new file mode 100644 index 0000000000000..2aa86544dd9e2 --- /dev/null +++ b/extensions/blocks/tiled-gallery/gallery-image/edit.js @@ -0,0 +1,196 @@ +/** + * External Dependencies + */ +import classnames from 'classnames'; +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { Component, createRef, Fragment } from '@wordpress/element'; +import { IconButton, Spinner } from '@wordpress/components'; +import { isBlobURL } from '@wordpress/blob'; +/* @TODO Caption has been commented out */ +// import { RichText } from '@wordpress/editor'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { __ } from '../../../utils/i18n'; + +class GalleryImageEdit extends Component { + img = createRef(); + + /* @TODO Caption has been commented out */ + // state = { + // captionSelected: false, + // }; + + // onSelectCaption = () => { + // if ( ! this.state.captionSelected ) { + // this.setState( { + // captionSelected: true, + // } ); + // } + + // if ( ! this.props.isSelected ) { + // this.props.onSelect(); + // } + // }; + + onImageClick = () => { + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + + // if ( this.state.captionSelected ) { + // this.setState( { + // captionSelected: false, + // } ); + // } + }; + + onImageKeyDown = event => { + if ( + this.img.current === document.activeElement && + this.props.isSelected && + [ BACKSPACE, DELETE ].includes( event.keyCode ) + ) { + this.props.onRemove(); + } + }; + + /* @TODO Caption has been commented out */ + // static getDerivedStateFromProps( props, state ) { + // // unselect the caption so when the user selects other image and comeback + // // the caption is not immediately selected + // if ( ! props.isSelected && state.captionSelected ) { + // return { captionSelected: false }; + // } + // return null; + // } + + componentDidUpdate() { + const { alt, height, image, link, url, width } = this.props; + + if ( image ) { + const nextAtts = {}; + + if ( ! alt && image.alt_text ) { + nextAtts.alt = image.alt_text; + } + if ( ! height && image.media_details && image.media_details.height ) { + nextAtts.height = +image.media_details.height; + } + if ( ! link && image.link ) { + nextAtts.link = image.link; + } + if ( ! url && image.source_url ) { + nextAtts.url = image.source_url; + } + if ( ! width && image.media_details && image.media_details.width ) { + nextAtts.width = +image.media_details.width; + } + + if ( Object.keys( nextAtts ).length ) { + this.props.setAttributes( nextAtts ); + } + } + } + + render() { + const { + 'aria-label': ariaLabel, + alt, + // caption, + height, + id, + imageFilter, + isSelected, + link, + linkTo, + onRemove, + origUrl, + // setAttributes, + url, + width, + } = this.props; + + let href; + + switch ( linkTo ) { + case 'media': + href = url; + break; + case 'attachment': + href = link; + break; + } + + const img = ( + // Disable reason: Image itself is not meant to be interactive, but should + // direct image selection and unfocus caption fields. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */ + <Fragment> + <img + alt={ alt } + aria-label={ ariaLabel } + data-height={ height } + data-id={ id } + data-link={ link } + data-url={ origUrl } + data-width={ width } + onClick={ this.onImageClick } + onKeyDown={ this.onImageKeyDown } + ref={ this.img } + src={ url } + tabIndex="0" + /> + { isBlobURL( origUrl ) && <Spinner /> } + </Fragment> + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */ + ); + + // Disable reason: Each block can be selected by clicking on it and we should keep the same saved markup + return ( + <figure + className={ classnames( 'tiled-gallery__item', { + 'is-selected': isSelected, + 'is-transient': isBlobURL( origUrl ), + [ `filter__${ imageFilter }` ]: !! imageFilter, + } ) } + > + { isSelected && ( + <div className="tiled-gallery__item__inline-menu"> + <IconButton + icon="no-alt" + onClick={ onRemove } + className="tiled-gallery__item__remove" + label={ __( 'Remove Image' ) } + /> + </div> + ) } + { /* Keep the <a> HTML structure, but ensure there is no navigation from edit */ + /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ } + { href ? <a>{ img }</a> : img } + { /* ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + isSelected={ this.state.captionSelected } + onChange={ newCaption => setAttributes( { caption: newCaption } ) } + unstableOnFocus={ this.onSelectCaption } + inlineToolbar + /> + ) */ } + </figure> + ); + } +} + +export default withSelect( ( select, ownProps ) => { + const { getMedia } = select( 'core' ); + const { id } = ownProps; + + return { + image: id ? getMedia( id ) : null, + }; +} )( GalleryImageEdit ); diff --git a/extensions/blocks/tiled-gallery/gallery-image/save.js b/extensions/blocks/tiled-gallery/gallery-image/save.js new file mode 100644 index 0000000000000..ac57133da7229 --- /dev/null +++ b/extensions/blocks/tiled-gallery/gallery-image/save.js @@ -0,0 +1,63 @@ +/** + * External Dependencies + */ +import classnames from 'classnames'; +import { isBlobURL } from '@wordpress/blob'; + +/* @TODO Caption has been commented out */ +// import { RichText } from '@wordpress/editor'; + +export default function GalleryImageSave( props ) { + const { + alt, + // caption, + imageFilter, + height, + id, + link, + linkTo, + origUrl, + url, + width, + } = props; + + if ( isBlobURL( origUrl ) ) { + return null; + } + + let href; + + switch ( linkTo ) { + case 'media': + href = url; + break; + case 'attachment': + href = link; + break; + } + + const img = ( + <img + alt={ alt } + data-height={ height } + data-id={ id } + data-link={ link } + data-url={ origUrl } + data-width={ width } + src={ url } + /> + ); + + return ( + <figure + className={ classnames( 'tiled-gallery__item', { + [ `filter__${ imageFilter }` ]: !! imageFilter, + } ) } + > + { href ? <a href={ href }>{ img }</a> : img } + { /* ! RichText.isEmpty( caption ) && ( + <RichText.Content tagName="figcaption" value={ caption } /> + ) */ } + </figure> + ); +} diff --git a/extensions/blocks/tiled-gallery/index.js b/extensions/blocks/tiled-gallery/index.js new file mode 100644 index 0000000000000..2a6d979a57019 --- /dev/null +++ b/extensions/blocks/tiled-gallery/index.js @@ -0,0 +1,190 @@ +/** + * External dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { filter } from 'lodash'; +import { Path, SVG } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from '../../utils/i18n'; +import edit from './edit'; +import save from './save'; +import { + LAYOUT_CIRCLE, + LAYOUT_COLUMN, + LAYOUT_DEFAULT, + LAYOUT_SQUARE, + LAYOUT_STYLES, +} from './constants'; + +/** + * Style dependencies + */ +import './editor.scss'; + +import * as deprecatedV1 from './deprecated/v1'; + +// Style names are translated. Avoid introducing an i18n dependency elsewhere (view) +// by only including the labels here, the only place they're needed. +// +// Map style names to labels and merge them together. +const styleNames = { + [ LAYOUT_DEFAULT ]: _x( 'Tiled mosaic', 'Tiled gallery layout' ), + [ LAYOUT_CIRCLE ]: _x( 'Circles', 'Tiled gallery layout' ), + [ LAYOUT_COLUMN ]: _x( 'Tiled columns', 'Tiled gallery layout' ), + [ LAYOUT_SQUARE ]: _x( 'Square tiles', 'Tiled gallery layout' ), +}; +const layoutStylesWithLabels = LAYOUT_STYLES.map( style => ( { + ...style, + label: styleNames[ style.name ], +} ) ); + +const blockAttributes = { + // Set default align + align: { + default: 'center', + type: 'string', + }, + // Set default className (used with block styles) + className: { + default: `is-style-${ LAYOUT_DEFAULT }`, + type: 'string', + }, + columns: { + type: 'number', + }, + ids: { + default: [], + type: 'array', + }, + imageFilter: { + type: 'string', + }, + images: { + type: 'array', + default: [], + source: 'query', + selector: '.tiled-gallery__item', + query: { + alt: { + attribute: 'alt', + default: '', + selector: 'img', + source: 'attribute', + }, + caption: { + selector: 'figcaption', + source: 'html', + type: 'string', + }, + height: { + attribute: 'data-height', + selector: 'img', + source: 'attribute', + type: 'number', + }, + id: { + attribute: 'data-id', + selector: 'img', + source: 'attribute', + }, + link: { + attribute: 'data-link', + selector: 'img', + source: 'attribute', + }, + url: { + attribute: 'data-url', + selector: 'img', + source: 'attribute', + }, + width: { + attribute: 'data-width', + selector: 'img', + source: 'attribute', + type: 'number', + }, + }, + }, + linkTo: { + default: 'none', + type: 'string', + }, +}; + +export const name = 'tiled-gallery'; + +export const icon = ( + <SVG viewBox="0 0 24 24" width={ 24 } height={ 24 }> + <Path + fill="currentColor" + d="M19 5v2h-4V5h4M9 5v6H5V5h4m10 8v6h-4v-6h4M9 17v2H5v-2h4M21 3h-8v6h8V3zM11 3H3v10h8V3zm10 8h-8v10h8V11zm-10 4H3v6h8v-6z" + /> + </SVG> +); + +export const settings = { + attributes: blockAttributes, + category: 'jetpack', + description: __( 'Display multiple images in an elegantly organized tiled layout.' ), + icon, + keywords: [ + _x( 'images', 'block search term' ), + _x( 'photos', 'block search term' ), + _x( 'masonry', 'block search term' ), + ], + styles: layoutStylesWithLabels, + supports: { + align: [ 'center', 'wide', 'full' ], + customClassName: false, + html: false, + }, + title: __( 'Tiled Gallery' ), + transforms: { + from: [ + { + type: 'block', + blocks: [ 'core/gallery' ], + transform: attributes => { + const validImages = filter( attributes.images, ( { id, url } ) => id && url ); + if ( validImages.length > 0 ) { + return createBlock( `jetpack/${ name }`, { + images: validImages.map( ( { id, url, alt, caption } ) => ( { + id, + url, + alt, + caption, + } ) ), + } ); + } + return createBlock( `jetpack/${ name }` ); + }, + }, + ], + to: [ + { + type: 'block', + blocks: [ 'core/gallery' ], + transform: ( { images, columns, linkTo } ) => + createBlock( 'core/gallery', { images, columns, imageCrop: true, linkTo } ), + }, + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( { images } ) => { + if ( images.length > 0 ) { + return images.map( ( { id, url, alt, caption } ) => + createBlock( 'core/image', { id, url, alt, caption } ) + ); + } + return createBlock( 'core/image' ); + }, + }, + ], + }, + edit, + save, + deprecated: [ deprecatedV1 ], +}; diff --git a/extensions/blocks/tiled-gallery/layout/column.js b/extensions/blocks/tiled-gallery/layout/column.js new file mode 100644 index 0000000000000..a3ed5cdf04cbb --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/column.js @@ -0,0 +1,3 @@ +export default function Column( { children } ) { + return <div className="tiled-gallery__col">{ children }</div>; +} diff --git a/extensions/blocks/tiled-gallery/layout/gallery.js b/extensions/blocks/tiled-gallery/layout/gallery.js new file mode 100644 index 0000000000000..94fc61e4be980 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/gallery.js @@ -0,0 +1,7 @@ +export default function Gallery( { children, galleryRef } ) { + return ( + <div className="tiled-gallery__gallery" ref={ galleryRef }> + { children } + </div> + ); +} diff --git a/extensions/blocks/tiled-gallery/layout/index.js b/extensions/blocks/tiled-gallery/layout/index.js new file mode 100644 index 0000000000000..e375ffe3063b9 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/index.js @@ -0,0 +1,159 @@ +/** + * External dependencies + */ +import photon from 'photon'; +import { Component } from '@wordpress/element'; +import { format as formatUrl, parse as parseUrl } from 'url'; +import { isBlobURL } from '@wordpress/blob'; +import { sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import GalleryImageEdit from '../gallery-image/edit'; +import GalleryImageSave from '../gallery-image/save'; +import Mosaic from './mosaic'; +import Square from './square'; +import { PHOTON_MAX_RESIZE } from '../constants'; +import { __ } from '../../../utils/i18n'; + +export default class Layout extends Component { + photonize( { height, width, url } ) { + if ( ! url ) { + return; + } + + // Do not Photonize images that are still uploading or from localhost + if ( isBlobURL( url ) || /^https?:\/\/localhost/.test( url ) ) { + return url; + } + + // Drop query args, photon URLs can't handle them + // This should be the "raw" url, we'll add dimensions later + const cleanUrl = url.split( '?', 1 )[ 0 ]; + + const photonImplementation = isWpcomFilesUrl( url ) ? photonWpcomImage : photon; + + const { layoutStyle } = this.props; + + if ( isSquareishLayout( layoutStyle ) && width && height ) { + const size = Math.min( PHOTON_MAX_RESIZE, width, height ); + return photonImplementation( cleanUrl, { resize: `${ size },${ size }` } ); + } + return photonImplementation( cleanUrl ); + } + + // This is tricky: + // - We need to "photonize" to resize the images at appropriate dimensions + // - The resize will depend on the image size and the layout in some cases + // - Handlers need to be created by index so that the image changes can be applied correctly. + // This is because the images are stored in an array in the block attributes. + renderImage( img, i ) { + const { + imageFilter, + images, + isSave, + linkTo, + onRemoveImage, + onSelectImage, + selectedImage, + setImageAttributes, + } = this.props; + + /* translators: %1$d is the order number of the image, %2$d is the total number of images. */ + const ariaLabel = sprintf( __( 'image %1$d of %2$d in gallery' ), i + 1, images.length ); + const Image = isSave ? GalleryImageSave : GalleryImageEdit; + + return ( + <Image + alt={ img.alt } + aria-label={ ariaLabel } + // @TODO Caption has been commented out + // caption={ img.caption } + height={ img.height } + id={ img.id } + imageFilter={ imageFilter } + isSelected={ selectedImage === i } + key={ i } + link={ img.link } + linkTo={ linkTo } + onRemove={ isSave ? undefined : onRemoveImage( i ) } + onSelect={ isSave ? undefined : onSelectImage( i ) } + origUrl={ img.url } + setAttributes={ isSave ? undefined : setImageAttributes( i ) } + url={ this.photonize( img ) } + width={ img.width } + /> + ); + } + + render() { + const { align, children, className, columns, images, layoutStyle } = this.props; + + const LayoutRenderer = isSquareishLayout( layoutStyle ) ? Square : Mosaic; + + const renderedImages = this.props.images.map( this.renderImage, this ); + + return ( + <div className={ className }> + <LayoutRenderer + align={ align } + columns={ columns } + images={ images } + layoutStyle={ layoutStyle } + renderedImages={ renderedImages } + /> + { children } + </div> + ); + } +} + +function isSquareishLayout( layout ) { + return [ 'circle', 'square' ].includes( layout ); +} + +function isWpcomFilesUrl( url ) { + const { host } = parseUrl( url ); + return /\.files\.wordpress\.com$/.test( host ); +} + +/** + * Apply photon arguments to *.files.wordpress.com images + * + * This function largely duplicates the functionlity of the photon.js lib. + * This is necessary because we want to serve images from *.files.wordpress.com so that private + * WordPress.com sites can use this block which depends on a Photon-like image service. + * + * If we pass all images through Photon servers, some images are unreachable. *.files.wordpress.com + * is already photon-like so we can pass it the same parameters for image resizing. + * + * @param {string} url Image url + * @param {Object} opts Options to pass to photon + * + * @return {string} Url string with options applied + */ +function photonWpcomImage( url, opts = {} ) { + // Adhere to the same options API as the photon.js lib + const photonLibMappings = { + width: 'w', + height: 'h', + letterboxing: 'lb', + removeLetterboxing: 'ulb', + }; + + // Discard some param parts + const { auth, hash, port, query, search, ...urlParts } = parseUrl( url ); + + // Build query + // This reduction intentionally mutates the query as it is built internally. + urlParts.query = Object.keys( opts ).reduce( + ( q, key ) => + Object.assign( q, { + [ photonLibMappings.hasOwnProperty( key ) ? photonLibMappings[ key ] : key ]: opts[ key ], + } ), + {} + ); + + return formatUrl( urlParts ); +} diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/index.js b/extensions/blocks/tiled-gallery/layout/mosaic/index.js new file mode 100644 index 0000000000000..8c56b1641dd1e --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/index.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { Component, createRef } from '@wordpress/element'; +import ResizeObserver from 'resize-observer-polyfill'; + +/** + * Internal dependencies + */ +import Column from '../column'; +import Gallery from '../gallery'; +import Row from '../row'; +import { getGalleryRows, handleRowResize } from './resize'; +import { imagesToRatios, ratiosToColumns, ratiosToMosaicRows } from './ratios'; + +export default class Mosaic extends Component { + gallery = createRef(); + pendingRaf = null; + ro = null; // resizeObserver instance + + componentDidMount() { + this.observeResize(); + } + + componentWillUnmount() { + this.unobserveResize(); + } + + componentDidUpdate( prevProps ) { + if ( prevProps.images !== this.props.images || prevProps.align !== this.props.align ) { + this.triggerResize(); + } else if ( 'columns' === this.props.layoutStyle && prevProps.columns !== this.props.columns ) { + this.triggerResize(); + } + } + + handleGalleryResize = entries => { + if ( this.pendingRaf ) { + cancelAnimationFrame( this.pendingRaf ); + this.pendingRaf = null; + } + this.pendingRaf = requestAnimationFrame( () => { + for ( const { contentRect, target } of entries ) { + const { width } = contentRect; + getGalleryRows( target ).forEach( row => handleRowResize( row, width ) ); + } + } ); + }; + + triggerResize() { + if ( this.gallery.current ) { + this.handleGalleryResize( [ + { + target: this.gallery.current, + contentRect: { width: this.gallery.current.clientWidth }, + }, + ] ); + } + } + + observeResize() { + this.triggerResize(); + this.ro = new ResizeObserver( this.handleGalleryResize ); + if ( this.gallery.current ) { + this.ro.observe( this.gallery.current ); + } + } + + unobserveResize() { + if ( this.ro ) { + this.ro.disconnect(); + this.ro = null; + } + if ( this.pendingRaf ) { + cancelAnimationFrame( this.pendingRaf ); + this.pendingRaf = null; + } + } + + render() { + const { align, columns, images, layoutStyle, renderedImages } = this.props; + + const ratios = imagesToRatios( images ); + const rows = + 'columns' === layoutStyle + ? ratiosToColumns( ratios, columns ) + : ratiosToMosaicRows( ratios, { isWide: [ 'full', 'wide' ].includes( align ) } ); + + let cursor = 0; + return ( + <Gallery galleryRef={ this.gallery }> + { rows.map( ( row, rowIndex ) => ( + <Row key={ rowIndex }> + { row.map( ( colSize, colIndex ) => { + const columnImages = renderedImages.slice( cursor, cursor + colSize ); + cursor += colSize; + return <Column key={ colIndex }>{ columnImages }</Column>; + } ) } + </Row> + ) ) } + </Gallery> + ); + } +} diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js b/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js new file mode 100644 index 0000000000000..8accd552b710a --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/ratios.js @@ -0,0 +1,280 @@ +/** + * External dependencies + */ +import { + drop, + every, + isEqual, + map, + overEvery, + some, + sum, + take, + takeRight, + takeWhile, + zipWith, +} from 'lodash'; + +export function imagesToRatios( images ) { + return map( images, ratioFromImage ); +} + +export function ratioFromImage( { height, width } ) { + return height && width ? width / height : 1; +} + +/** + * Build three columns, each of which should contain approximately 1/3 of the total ratio + * + * @param {Array.<number>} ratios Ratios of images put into shape + * @param {number} columnCount Number of columns + * + * @return {Array.<Array.<number>>} Shape of rows and columns + */ +export function ratiosToColumns( ratios, columnCount ) { + // If we don't have more than 1 per column, just return a simple 1 ratio per column shape + if ( ratios.length <= columnCount ) { + return [ Array( ratios.length ).fill( 1 ) ]; + } + + const total = sum( ratios ); + const targetColRatio = total / columnCount; + + const row = []; + let toProcess = ratios; + let accumulatedRatio = 0; + + // We skip the last column in the loop and add rest later + for ( let i = 0; i < columnCount - 1; i++ ) { + const colSize = takeWhile( toProcess, ratio => { + const shouldTake = accumulatedRatio <= ( i + 1 ) * targetColRatio; + if ( shouldTake ) { + accumulatedRatio += ratio; + } + return shouldTake; + } ).length; + row.push( colSize ); + toProcess = drop( toProcess, colSize ); + } + + // Don't calculate last column, just add what's left + row.push( toProcess.length ); + + // A shape is an array of rows. Wrap our row in an array. + return [ row ]; +} + +/** + * These are partially applied functions. + * They rely on helper function (defined below) to create a function that expects to be passed ratios + * during processing. + * + * …FitsNextImages() functions should be passed ratios to be processed + * …IsNotRecent() functions should be passed the processed shapes + */ + +const reverseSymmetricRowIsNotRecent = isNotRecentShape( [ 2, 1, 2 ], 5 ); +const reverseSymmetricFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isPortrait, + isLandscape, + isLandscape, +] ); +const longSymmetricRowFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isLandscape, + isPortrait, + isLandscape, + isLandscape, + isLandscape, +] ); +const longSymmetricRowIsNotRecent = isNotRecentShape( [ 3, 1, 3 ], 5 ); +const symmetricRowFitsNextImages = checkNextRatios( [ + isPortrait, + isLandscape, + isLandscape, + isPortrait, +] ); +const symmetricRowIsNotRecent = isNotRecentShape( [ 1, 2, 1 ], 5 ); +const oneThreeFitsNextImages = checkNextRatios( [ + isPortrait, + isLandscape, + isLandscape, + isLandscape, +] ); +const oneThreeIsNotRecent = isNotRecentShape( [ 1, 3 ], 3 ); +const threeOneIsFitsNextImages = checkNextRatios( [ + isLandscape, + isLandscape, + isLandscape, + isPortrait, +] ); +const threeOneIsNotRecent = isNotRecentShape( [ 3, 1 ], 3 ); +const oneTwoFitsNextImages = checkNextRatios( [ + lt( 1.6 ), + overEvery( gte( 0.9 ), lt( 2 ) ), + overEvery( gte( 0.9 ), lt( 2 ) ), +] ); +const oneTwoIsNotRecent = isNotRecentShape( [ 1, 2 ], 3 ); +const fiveIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1, 1 ], 1 ); +const fourIsNotRecent = isNotRecentShape( [ 1, 1, 1, 1 ], 1 ); +const threeIsNotRecent = isNotRecentShape( [ 1, 1, 1 ], 3 ); +const twoOneFitsNextImages = checkNextRatios( [ + overEvery( gte( 0.9 ), lt( 2 ) ), + overEvery( gte( 0.9 ), lt( 2 ) ), + lt( 1.6 ), +] ); +const twoOneIsNotRecent = isNotRecentShape( [ 2, 1 ], 3 ); +const panoramicFitsNextImages = checkNextRatios( [ isPanoramic ] ); + +export function ratiosToMosaicRows( ratios, { isWide } = {} ) { + // This function will recursively process the input until it is consumed + const go = ( processed, toProcess ) => { + if ( ! toProcess.length ) { + return processed; + } + + let next; + + if ( + /* Reverse_Symmetric_Row */ + toProcess.length > 15 && + reverseSymmetricFitsNextImages( toProcess ) && + reverseSymmetricRowIsNotRecent( processed ) + ) { + next = [ 2, 1, 2 ]; + } else if ( + /* Long_Symmetric_Row */ + toProcess.length > 15 && + longSymmetricRowFitsNextImages( toProcess ) && + longSymmetricRowIsNotRecent( processed ) + ) { + next = [ 3, 1, 3 ]; + } else if ( + /* Symmetric_Row */ + toProcess.length !== 5 && + symmetricRowFitsNextImages( toProcess ) && + symmetricRowIsNotRecent( processed ) + ) { + next = [ 1, 2, 1 ]; + } else if ( + /* One_Three */ + oneThreeFitsNextImages( toProcess ) && + oneThreeIsNotRecent( processed ) + ) { + next = [ 1, 3 ]; + } else if ( + /* Three_One */ + threeOneIsFitsNextImages( toProcess ) && + threeOneIsNotRecent( processed ) + ) { + next = [ 3, 1 ]; + } else if ( + /* One_Two */ + oneTwoFitsNextImages( toProcess ) && + oneTwoIsNotRecent( processed ) + ) { + next = [ 1, 2 ]; + } else if ( + /* Five */ + isWide && + ( toProcess.length === 5 || ( toProcess.length !== 10 && toProcess.length > 6 ) ) && + fiveIsNotRecent( processed ) && + sum( take( toProcess, 5 ) ) < 5 + ) { + next = [ 1, 1, 1, 1, 1 ]; + } else if ( + /* Four */ + isFourValidCandidate( processed, toProcess ) + ) { + next = [ 1, 1, 1, 1 ]; + } else if ( + /* Three */ + isThreeValidCandidate( processed, toProcess, isWide ) + ) { + next = [ 1, 1, 1 ]; + } else if ( + /* Two_One */ + twoOneFitsNextImages( toProcess ) && + twoOneIsNotRecent( processed ) + ) { + next = [ 2, 1 ]; + } else if ( /* Panoramic */ panoramicFitsNextImages( toProcess ) ) { + next = [ 1 ]; + } else if ( /* One_One */ toProcess.length > 3 ) { + next = [ 1, 1 ]; + } else { + // Everything left + next = Array( toProcess.length ).fill( 1 ); + } + + // Add row + const nextProcessed = processed.concat( [ next ] ); + + // Trim consumed images from next processing step + const consumedImages = sum( next ); + const nextToProcess = toProcess.slice( consumedImages ); + + return go( nextProcessed, nextToProcess ); + }; + return go( [], ratios ); +} + +function isThreeValidCandidate( processed, toProcess, isWide ) { + const ratio = sum( take( toProcess, 3 ) ); + return ( + toProcess.length >= 3 && + toProcess.length !== 4 && + toProcess.length !== 6 && + threeIsNotRecent( processed ) && + ( ratio < 2.5 || + ( ratio < 5 && + /* nextAreSymettric */ + ( toProcess.length >= 3 && + /* @FIXME floating point equality?? */ toProcess[ 0 ] === toProcess[ 2 ] ) ) || + isWide ) + ); +} + +function isFourValidCandidate( processed, toProcess ) { + const ratio = sum( take( toProcess, 4 ) ); + return ( + ( fourIsNotRecent( processed ) && ( ratio < 3.5 && toProcess.length > 5 ) ) || + ( ratio < 7 && toProcess.length === 4 ) + ); +} + +function isNotRecentShape( shape, numRecents ) { + return recents => + ! some( takeRight( recents, numRecents ), recentShape => isEqual( recentShape, shape ) ); +} + +function checkNextRatios( shape ) { + return ratios => + ratios.length >= shape.length && + every( zipWith( shape, ratios.slice( 0, shape.length ), ( f, r ) => f( r ) ) ); +} + +function isLandscape( ratio ) { + return ratio >= 1 && ratio < 2; +} + +function isPortrait( ratio ) { + return ratio < 1; +} + +function isPanoramic( ratio ) { + return ratio >= 2; +} + +// >= +function gte( n ) { + return m => m >= n; +} + +// < +function lt( n ) { + return m => m < n; +} diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/resize.js b/extensions/blocks/tiled-gallery/layout/mosaic/resize.js new file mode 100644 index 0000000000000..022729c8bac72 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/resize.js @@ -0,0 +1,107 @@ +/** + * Internal dependencies + */ +import { GUTTER_WIDTH } from '../../constants'; + +/** + * Distribute a difference across ns so that their sum matches the target + * + * @param {Array<number>} parts Array of numbers to fit + * @param {number} target Number that sum should match + * @return {Array<number>} Adjusted parts + */ +function adjustFit( parts, target ) { + const diff = target - parts.reduce( ( sum, n ) => sum + n, 0 ); + const partialDiff = diff / parts.length; + return parts.map( p => p + partialDiff ); +} + +export function handleRowResize( row, width ) { + applyRowRatio( row, getRowRatio( row ), width ); +} + +function getRowRatio( row ) { + const result = getRowCols( row ) + .map( getColumnRatio ) + .reduce( + ( [ ratioA, weightedRatioA ], [ ratioB, weightedRatioB ] ) => { + return [ ratioA + ratioB, weightedRatioA + weightedRatioB ]; + }, + [ 0, 0 ] + ); + return result; +} + +export function getGalleryRows( gallery ) { + return Array.from( gallery.querySelectorAll( '.tiled-gallery__row' ) ); +} + +function getRowCols( row ) { + return Array.from( row.querySelectorAll( '.tiled-gallery__col' ) ); +} + +function getColImgs( col ) { + return Array.from( + col.querySelectorAll( '.tiled-gallery__item > img, .tiled-gallery__item > a > img' ) + ); +} + +function getColumnRatio( col ) { + const imgs = getColImgs( col ); + const imgCount = imgs.length; + const ratio = + 1 / + imgs.map( getImageRatio ).reduce( ( partialColRatio, imgRatio ) => { + return partialColRatio + 1 / imgRatio; + }, 0 ); + const result = [ ratio, ratio * imgCount || 1 ]; + return result; +} + +function getImageRatio( img ) { + const w = parseInt( img.dataset.width, 10 ); + const h = parseInt( img.dataset.height, 10 ); + const result = w && ! Number.isNaN( w ) && h && ! Number.isNaN( h ) ? w / h : 1; + return result; +} + +function applyRowRatio( row, [ ratio, weightedRatio ], width ) { + const rawHeight = + ( 1 / ratio ) * ( width - GUTTER_WIDTH * ( row.childElementCount - 1 ) - weightedRatio ); + + applyColRatio( row, { + rawHeight, + rowWidth: width - GUTTER_WIDTH * ( row.childElementCount - 1 ), + } ); +} + +function applyColRatio( row, { rawHeight, rowWidth } ) { + const cols = getRowCols( row ); + + const colWidths = cols.map( + col => ( rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ) ) * getColumnRatio( col )[ 0 ] + ); + + const adjustedWidths = adjustFit( colWidths, rowWidth ); + + cols.forEach( ( col, i ) => { + const rawWidth = colWidths[ i ]; + const width = adjustedWidths[ i ]; + applyImgRatio( col, { + colHeight: rawHeight - GUTTER_WIDTH * ( col.childElementCount - 1 ), + width, + rawWidth, + } ); + } ); +} + +function applyImgRatio( col, { colHeight, width, rawWidth } ) { + const imgHeights = getColImgs( col ).map( img => rawWidth / getImageRatio( img ) ); + const adjustedHeights = adjustFit( imgHeights, colHeight ); + + // Set size of col children, not the <img /> element + Array.from( col.children ).forEach( ( item, i ) => { + const height = adjustedHeights[ i ]; + item.setAttribute( 'style', `height:${ height }px;width:${ width }px;` ); + } ); +} diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap b/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..e726fa521fa5b --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/index.js.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders as expected 1`] = ` +<Gallery + galleryRef={ + Object { + "current": null, + } + } +> + <Row + key="0" + > + <Column + key="0" + > + 0 + </Column> + </Row> + <Row + key="1" + > + <Column + key="0" + > + 1 + </Column> + </Row> + <Row + key="2" + > + <Column + key="0" + > + 2 + </Column> + <Column + key="1" + > + 3 + </Column> + <Column + key="2" + > + 4 + </Column> + <Column + key="3" + > + 5 + </Column> + </Row> + <Row + key="3" + > + <Column + key="0" + > + 6 + </Column> + <Column + key="1" + > + 7 + </Column> + </Row> + <Row + key="4" + > + <Column + key="0" + > + 8 + </Column> + <Column + key="1" + > + 9 + 10 + </Column> + </Row> + <Row + key="5" + > + <Column + key="0" + > + 11 + 12 + </Column> + <Column + key="1" + > + 13 + </Column> + </Row> +</Gallery> +`; diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap b/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap new file mode 100644 index 0000000000000..df02118c594b9 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/test/__snapshots__/ratios.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ratiosToMosaicRows transforms as expected 1`] = ` +Array [ + Array [ + 1, + ], + Array [ + 1, + ], + Array [ + 1, + 1, + 1, + 1, + ], + Array [ + 1, + 1, + ], + Array [ + 1, + 2, + ], + Array [ + 2, + 1, + ], +] +`; diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js b/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js new file mode 100644 index 0000000000000..77db288c5b0f8 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/test/fixtures/ratios.js @@ -0,0 +1,16 @@ +export const ratios = [ + 4, + 2.26056338028169, + 0.6676143094053542, + 0.75, + 0.7444409646100846, + 0.6666666666666666, + 0.8000588062334607, + 3.6392174704276616, + 1.335559265442404, + 1.509433962264151, + 1.6, + 1.3208430913348945, + 1.3553937789543349, + 1.499531396438613, +]; diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js b/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js new file mode 100644 index 0000000000000..57e991d23b463 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/test/index.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import Adapter from 'enzyme-adapter-react-16'; +import Enzyme, { shallow } from 'enzyme'; +import React from 'react'; +import { createSerializer } from 'enzyme-to-json'; +import { range } from 'lodash'; + +/** + * Internal dependencies + */ +import Mosaic from '..'; +import * as imageSets from '../../test/fixtures/image-sets'; + +Enzyme.configure( { adapter: new Adapter() } ); +expect.addSnapshotSerializer( createSerializer( { mode: 'deep' } ) ); + +test( 'renders as expected', () => { + Object.keys( imageSets ).forEach( k => { + const images = imageSets[ k ]; + expect( + shallow( <Mosaic images={ images } renderedImages={ range( images.length ) } /> ) + ).toMatchSnapshot(); + } ); +} ); diff --git a/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js b/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js new file mode 100644 index 0000000000000..3756b971e03ce --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/mosaic/test/ratios.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import { ratiosToMosaicRows } from '../ratios'; +import { ratios } from './fixtures/ratios'; + +describe( 'ratiosToMosaicRows', () => { + test( 'transforms as expected', () => { + expect( ratiosToMosaicRows( ratios ) ).toMatchSnapshot(); + } ); +} ); diff --git a/extensions/blocks/tiled-gallery/layout/row.js b/extensions/blocks/tiled-gallery/layout/row.js new file mode 100644 index 0000000000000..200a58c2e3acf --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/row.js @@ -0,0 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +export default function Row( { children, className } ) { + return <div className={ classnames( 'tiled-gallery__row', className ) }>{ children }</div>; +} diff --git a/extensions/blocks/tiled-gallery/layout/square.js b/extensions/blocks/tiled-gallery/layout/square.js new file mode 100644 index 0000000000000..2a1ab888b1916 --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/square.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { chunk, drop, take } from 'lodash'; + +/** + * Internal dependencies + */ +import Row from './row'; +import Column from './column'; +import Gallery from './gallery'; +import { MAX_COLUMNS } from '../constants'; + +export default function Square( { columns, renderedImages } ) { + const columnCount = Math.min( MAX_COLUMNS, columns ); + + const remainder = renderedImages.length % columnCount; + + return ( + <Gallery> + { [ + ...( remainder ? [ take( renderedImages, remainder ) ] : [] ), + ...chunk( drop( renderedImages, remainder ), columnCount ), + ].map( ( imagesInRow, rowIndex ) => ( + <Row key={ rowIndex } className={ `columns-${ imagesInRow.length }` }> + { imagesInRow.map( ( image, colIndex ) => ( + <Column key={ colIndex }>{ image }</Column> + ) ) } + </Row> + ) ) } + </Gallery> + ); +} diff --git a/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js b/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js new file mode 100644 index 0000000000000..07c6bef746a9a --- /dev/null +++ b/extensions/blocks/tiled-gallery/layout/test/fixtures/image-sets.js @@ -0,0 +1,117 @@ +export const imageSet1 = [ + { + alt: '', + id: 163, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/architecture-bay-bridge-356830.jpg', + height: 2048, + width: 8192, + }, + { + alt: '', + id: 162, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/bloom-blossom-flora-40797-1.jpg', + height: 1562, + width: 3531, + }, + { + alt: '', + id: 161, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/architecture-building-city-597049.jpg', + height: 4221, + width: 2818, + }, + { + alt: '', + id: 160, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/architecture-art-blue-699466.jpg', + height: 4032, + width: 3024, + }, + { + alt: '', + id: 159, + caption: '', + url: + 'https://example.files.wordpress.com/2018/12/black-and-white-construction-ladder-54335.jpg', + height: 3193, + width: 2377, + }, + { + alt: '', + id: 158, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/architecture-buildings-city-1672110.jpg', + height: 6000, + width: 4000, + }, + { + alt: '', + id: 157, + caption: '', + url: + 'https://example.files.wordpress.com/2018/12/architectural-design-architecture-black-and-white-1672122-1.jpg', + height: 3401, + width: 2721, + }, + { + alt: '', + id: 156, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/grass-hd-wallpaper-lake-127753.jpg', + height: 2198, + width: 7999, + }, + { + alt: '', + id: 122, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/texaco-car-1.jpg', + height: 599, + width: 800, + }, + { + alt: '', + id: 92, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/43824553435_ea38cbc92a_m.jpg', + height: 159, + width: 240, + }, + { + alt: '', + id: 90, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/42924685680_7b5632e58e_m.jpg', + height: 150, + width: 240, + }, + { + alt: '', + id: 89, + caption: '', + url: + 'https://example.files.wordpress.com/2018/12/31962299833_1e106f7f7a_z-1-e1545262352979.jpg', + height: 427, + width: 564, + }, + { + alt: '', + id: 88, + caption: '', + url: 'https://example.files.wordpress.com/2018/12/29797558147_3c72afa8f4_k.jpg', + height: 1511, + width: 2048, + }, + { + alt: '', + id: 8, + caption: '', + url: 'https://example.files.wordpress.com/2018/11/person-smartphone-office-table.jpeg', + height: 1067, + width: 1600, + }, +]; diff --git a/extensions/blocks/tiled-gallery/save.js b/extensions/blocks/tiled-gallery/save.js new file mode 100644 index 0000000000000..8d69444ff5309 --- /dev/null +++ b/extensions/blocks/tiled-gallery/save.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import Layout from './layout'; +import { defaultColumnsNumber } from './edit'; +import { getActiveStyleName } from '../../utils'; +import { LAYOUT_STYLES } from './constants'; + +export default function TiledGallerySave( { attributes } ) { + const { imageFilter, images } = attributes; + + if ( ! images.length ) { + return null; + } + + const { align, className, columns = defaultColumnsNumber( attributes ), linkTo } = attributes; + + return ( + <Layout + align={ align } + className={ className } + columns={ columns } + imageFilter={ imageFilter } + images={ images } + isSave + layoutStyle={ getActiveStyleName( LAYOUT_STYLES, className ) } + linkTo={ linkTo } + /> + ); +} diff --git a/extensions/blocks/tiled-gallery/variables.scss b/extensions/blocks/tiled-gallery/variables.scss new file mode 100644 index 0000000000000..6472bae167b0b --- /dev/null +++ b/extensions/blocks/tiled-gallery/variables.scss @@ -0,0 +1,5 @@ +$tiled-gallery-add-item-border-color: #555d66; // Gutenberg $dark-gray-500 +$tiled-gallery-add-item-border-width: 1px; // Gutenberg $border-width +$tiled-gallery-caption-background-color: #000; +$tiled-gallery-gutter: 4px; // Fixed in JS, see `LayoutStyles` from `edit.jsx` +$tiled-gallery-selection: #0085ba; // Gutenberg primary theme color (https://github.com/WordPress/gutenberg/blob/6928e41c8afd7daa3a709afdda7eee48218473b7/bin/packages/post-css-config.js#L4) diff --git a/extensions/blocks/tiled-gallery/view.js b/extensions/blocks/tiled-gallery/view.js new file mode 100644 index 0000000000000..1f45b13ddc347 --- /dev/null +++ b/extensions/blocks/tiled-gallery/view.js @@ -0,0 +1,64 @@ +/** + * Internal dependencies + */ +import './view.scss'; +import ResizeObserver from 'resize-observer-polyfill'; +import { handleRowResize } from './layout/mosaic/resize'; + +/** + * Handler for Gallery ResizeObserver + * + * @param {Array<ResizeObserverEntry>} galleries Resized galleries + */ +function handleObservedResize( galleries ) { + if ( handleObservedResize.pendingRaf ) { + cancelAnimationFrame( handleObservedResize.pendingRaf ); + } + handleObservedResize.pendingRaf = requestAnimationFrame( () => { + handleObservedResize.pendingRaf = null; + for ( const gallery of galleries ) { + const { width: galleryWidth } = gallery.contentRect; + // We can't use childNodes becuase post content may contain unexpected text nodes + const rows = Array.from( gallery.target.querySelectorAll( '.tiled-gallery__row' ) ); + rows.forEach( row => handleRowResize( row, galleryWidth ) ); + } + } ); +} + +/** + * Get all the galleries on the document + * + * @return {Array} List of gallery nodes + */ +function getGalleries() { + return Array.from( + document.querySelectorAll( + '.wp-block-jetpack-tiled-gallery.is-style-rectangular > .tiled-gallery__gallery,' + + '.wp-block-jetpack-tiled-gallery.is-style-columns > .tiled-gallery__gallery' + ) + ); +} + +/** + * Setup ResizeObserver to follow each gallery on the page + */ +const observeGalleries = () => { + const galleries = getGalleries(); + + if ( galleries.length === 0 ) { + return; + } + + const observer = new ResizeObserver( handleObservedResize ); + + galleries.forEach( gallery => observer.observe( gallery ) ); +}; + +if ( typeof window !== 'undefined' && typeof document !== 'undefined' ) { + // `DOMContentLoaded` may fire before the script has a chance to run + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', observeGalleries ); + } else { + observeGalleries(); + } +} diff --git a/extensions/blocks/tiled-gallery/view.scss b/extensions/blocks/tiled-gallery/view.scss new file mode 100644 index 0000000000000..d7e475c5d6244 --- /dev/null +++ b/extensions/blocks/tiled-gallery/view.scss @@ -0,0 +1,135 @@ +@import './variables.scss'; +@import './css-gram.scss'; + +$tiled-gallery-max-column-count: 20; + +.wp-block-jetpack-tiled-gallery { + margin: 0 auto 1.5em; + + &.is-style-circle .tiled-gallery__item img { + border-radius: 50%; + } + + &.is-style-square, + &.is-style-circle { + .tiled-gallery__row { + flex-grow: 1; + width: 100%; + + @for $cols from 1 through $tiled-gallery-max-column-count { + &.columns-#{$cols} { + .tiled-gallery__col { + width: calc( ( 100% - ( #{$tiled-gallery-gutter} * ( #{$cols} - 1 ) ) ) / #{$cols} ); + } + } + } + } + } + + &.is-style-columns, + &.is-style-rectangular { + .tiled-gallery__item { + display: flex; + } + } +} + +.tiled-gallery__gallery { + width: 100%; + display: flex; + padding: 0; + flex-wrap: wrap; +} + +.tiled-gallery__row { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + margin: 0; + + & + & { + margin-top: $tiled-gallery-gutter; + } +} + +.tiled-gallery__col { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0; + + & + & { + margin-left: $tiled-gallery-gutter; + } +} + +.tiled-gallery__item { + justify-content: center; + margin: 0; + overflow: hidden; + padding: 0; + position: relative; + + &.filter__black-and-white { + filter: grayscale( 100% ); + } + + &.filter__sepia { + filter: sepia( 100% ); + } + + &.filter__1977 { + @include _1977; + } + + &.filter__clarendon { + @include clarendon; + } + + &.filter__gingham { + @include gingham; + } + + & + & { + margin-top: $tiled-gallery-gutter; + } + + > img { + background-color: rgba( 0, 0, 0, 0.1 ); + } + + > a, + > a > img, + > img { + display: block; + height: auto; + margin: 0; + max-width: 100%; + object-fit: cover; + object-position: center; + padding: 0; + width: 100%; + } + + /* @TODO Caption has been commented out */ + // figcaption { + // position: absolute; + // bottom: 0; + // width: 100%; + // max-height: 100%; + // overflow: auto; + // padding: 40px 10px 5px; + // color: var( --color-white ); + // text-align: center; + // font-size: $root-font-size; + // // stylelint-disable function-parentheses-space-inside + // background: linear-gradient( + // 0deg, + // rgba( $color: $tiled-gallery-caption-background-color, $alpha: 0.7 ) 0, + // rgba( $color: $tiled-gallery-caption-background-color, $alpha: 0.3 ) 60%, + // transparent + // ); + // // stylelint-enable function-parentheses-space-inside + // } +} diff --git a/extensions/blocks/videopress/edit.js b/extensions/blocks/videopress/edit.js new file mode 100644 index 0000000000000..ab3e487a99e1d --- /dev/null +++ b/extensions/blocks/videopress/edit.js @@ -0,0 +1,185 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { isBlobURL } from '@wordpress/blob'; +import { compose, createHigherOrderComponent } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; +import { Disabled, IconButton, SandBox, Toolbar } from '@wordpress/components'; +import { BlockControls, RichText } from '@wordpress/editor'; +import { Component, createRef, Fragment } from '@wordpress/element'; +import classnames from 'classnames'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import Loading from './loading'; + +const VideoPressEdit = CoreVideoEdit => + class extends Component { + constructor() { + super( ...arguments ); + this.state = { + media: null, + isFetchingMedia: false, + fallback: false, + }; + this.posterImageButton = createRef(); + } + + componentDidMount() { + const { guid } = this.props.attributes; + if ( ! guid ) { + this.setGuid(); + } + } + + componentDidUpdate( prevProps ) { + const { attributes } = this.props; + + if ( attributes.id !== prevProps.attributes.id ) { + this.setGuid(); + } + } + + fallbackToCore = () => { + this.props.setAttributes( { guid: undefined } ); + this.setState( { fallback: true } ); + }; + + setGuid = async () => { + const { attributes, setAttributes } = this.props; + const { id } = attributes; + + if ( ! id ) { + setAttributes( { guid: undefined } ); + return; + } + + try { + this.setState( { isFetchingMedia: true } ); + const media = await apiFetch( { path: `/wp/v2/media/${ id }` } ); + this.setState( { isFetchingMedia: false } ); + + const { id: currentId } = this.props.attributes; + if ( id !== currentId ) { + // Video was changed in the editor while fetching data for the previous video; + return; + } + + this.setState( { media } ); + const guid = get( media, 'jetpack_videopress_guid' ); + if ( guid ) { + setAttributes( { guid } ); + } else { + this.fallbackToCore(); + } + } catch ( e ) { + this.setState( { isFetchingMedia: false } ); + this.fallbackToCore(); + } + }; + + switchToEditing = () => { + this.props.setAttributes( { + id: undefined, + guid: undefined, + src: undefined, + } ); + }; + + onRemovePoster = () => { + this.props.setAttributes( { poster: '' } ); + + // Move focus back to the Media Upload button. + this.posterImageButton.current.focus(); + }; + + render() { + const { + attributes, + className, + isFetchingPreview, + isSelected, + isUploading, + preview, + setAttributes, + } = this.props; + const { fallback, isFetchingMedia } = this.state; + + if ( isUploading ) { + return <Loading text={ __( 'Uploading…' ) } />; + } + + if ( isFetchingMedia || isFetchingPreview ) { + return <Loading text={ __( 'Embedding…' ) } />; + } + + if ( fallback || ! preview ) { + return <CoreVideoEdit { ...this.props } />; + } + + const { html, scripts } = preview; + const { caption } = attributes; + + return ( + <Fragment> + <BlockControls> + <Toolbar> + <IconButton + className="components-icon-button components-toolbar__control" + label={ __( 'Edit video' ) } + onClick={ this.switchToEditing } + icon="edit" + /> + </Toolbar> + </BlockControls> + <figure className={ classnames( className, 'wp-block-embed', 'is-type-video' ) }> + { /* + Disable the video player so the user clicking on it won't play the + video when the controls are enabled. + */ } + <Disabled> + <div className="wp-block-embed__wrapper"> + <SandBox html={ html } scripts={ scripts } /> + </div> + </Disabled> + { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + tagName="figcaption" + placeholder={ __( 'Write caption…' ) } + value={ caption } + onChange={ value => setAttributes( { caption: value } ) } + inlineToolbar + /> + ) } + </figure> + </Fragment> + ); + } + }; + +export default createHigherOrderComponent( + compose( [ + withSelect( ( select, ownProps ) => { + const { guid, src } = ownProps.attributes; + const { getEmbedPreview, isRequestingEmbedPreview } = select( 'core' ); + + const url = !! guid && `https://videopress.com/v/${ guid }`; + const preview = !! url && getEmbedPreview( url ); + + const isFetchingEmbedPreview = !! url && isRequestingEmbedPreview( url ); + const isUploading = isBlobURL( src ); + + return { + isFetchingPreview: isFetchingEmbedPreview, + isUploading, + preview, + }; + } ), + VideoPressEdit, + ] ), + 'withVideoPressEdit' +); diff --git a/extensions/blocks/videopress/editor.js b/extensions/blocks/videopress/editor.js new file mode 100644 index 0000000000000..c8d2e87d82cf1 --- /dev/null +++ b/extensions/blocks/videopress/editor.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { createBlobURL } from '@wordpress/blob'; +import { createBlock } from '@wordpress/blocks'; +import { mediaUpload } from '@wordpress/editor'; +import { addFilter } from '@wordpress/hooks'; +import { every } from 'lodash'; + +/** + * Internal dependencies + */ +import withVideoPressEdit from './edit'; +import withVideoPressSave from './save'; +import getJetpackExtensionAvailability from '../../utils/get-jetpack-extension-availability'; + +const addVideoPressSupport = ( settings, name ) => { + if ( 'core/video' !== name ) { + return settings; + } + + const { available, unavailableReason } = getJetpackExtensionAvailability( 'videopress' ); + + // We customize the video block even if VideoPress it not available so we can support videos that were uploaded to + // VideoPress if it was available in the past (i.e. before a plan downgrade). + if ( available || [ 'missing_plan', 'missing_module' ].includes( unavailableReason ) ) { + return { + ...settings, + + attributes: { + autoplay: { + type: 'boolean', + }, + caption: { + type: 'string', + source: 'html', + selector: 'figcaption', + }, + controls: { + type: 'boolean', + default: true, + }, + guid: { + type: 'string', + }, + id: { + type: 'number', + }, + loop: { + type: 'boolean', + }, + muted: { + type: 'boolean', + }, + poster: { + type: 'string', + }, + preload: { + type: 'string', + default: 'metadata', + }, + src: { + type: 'string', + }, + }, + + transforms: { + ...settings.transforms, + from: [ + { + type: 'files', + isMatch: files => every( files, file => file.type.indexOf( 'video/' ) === 0 ), + // We define a higher priority (lower number) than the default of 10. This ensures that this + // transformation prevails over the core video block default transformations. + priority: 9, + transform: ( files, onChange ) => { + const blocks = []; + files.forEach( file => { + const block = createBlock( 'core/video', { + src: createBlobURL( file ), + } ); + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ { id, url } ] ) => { + onChange( block.clientId, { id, src: url } ); + }, + allowedTypes: [ 'video' ], + } ); + blocks.push( block ); + } ); + return blocks; + }, + }, + ], + }, + + supports: { + ...settings.supports, + reusable: false, + }, + + edit: withVideoPressEdit( settings.edit ), + + save: withVideoPressSave( settings.save ), + + deprecated: [ + { + attributes: settings.attributes, + save: settings.save, + isEligible: attrs => ! attrs.guid, + }, + ], + }; + } + + return settings; +}; + +addFilter( 'blocks.registerBlockType', 'jetpack/videopress', addVideoPressSupport ); diff --git a/extensions/blocks/videopress/index.js b/extensions/blocks/videopress/index.js new file mode 100644 index 0000000000000..60d3531f4ddc5 --- /dev/null +++ b/extensions/blocks/videopress/index.js @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +// Register the hook that customize the core video block +import './editor'; + +// This is exporting deliberately an empty object so we don't break `getExtensions` +// at the same time we don't register any new plugin or block +export const settings = {}; diff --git a/extensions/blocks/videopress/loading.js b/extensions/blocks/videopress/loading.js new file mode 100644 index 0000000000000..76c25d4cf06a8 --- /dev/null +++ b/extensions/blocks/videopress/loading.js @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import { Spinner } from '@wordpress/components'; + +const Loading = ( { text } ) => ( + <div className="wp-block-embed is-loading"> + <Spinner /> + <p>{ text }</p> + </div> +); + +export default Loading; diff --git a/extensions/blocks/videopress/save.js b/extensions/blocks/videopress/save.js new file mode 100644 index 0000000000000..52790480769af --- /dev/null +++ b/extensions/blocks/videopress/save.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { RichText } from '@wordpress/editor'; + +const VideoPressSave = CoreVideoSave => props => { + const { attributes: { caption, guid } = {} } = props; + + if ( ! guid ) { + /** + * We return the element produced by the render so Gutenberg can add the block class when cloning the element. + * This is due to the fact that `React.cloneElement` ignores the class name when we clone a component to be + * rendered (i.e. `React.cloneElement( <CoreVideoSave { ...props } />, { className: 'wp-block-video' } )`). + * + * @see https://github.com/WordPress/gutenberg/blob/3f1324b53cc8bb45d08d12d5321d6f88510bed09/packages/blocks/src/api/serializer.js#L78-L96 + * @see https://github.com/WordPress/gutenberg/blob/c5f9bd88125282a0c35f887cc8d835f065893112/packages/editor/src/hooks/generated-class-name.js#L42 + * @see https://github.com/Automattic/wp-calypso/pull/30546#issuecomment-463637946 + */ + return CoreVideoSave( props ); + } + + const url = `https://videopress.com/v/${ guid }`; + + return ( + <figure className="wp-block-embed is-type-video is-provider-videopress"> + <div className="wp-block-embed__wrapper"> + { `\n${ url }\n` /* URL needs to be on its own line. */ } + </div> + { ! RichText.isEmpty( caption ) && ( + <RichText.Content tagName="figcaption" value={ caption } /> + ) } + </figure> + ); +}; + +export default createHigherOrderComponent( VideoPressSave, 'withVideoPressSave' ); diff --git a/extensions/blocks/vr/edit.js b/extensions/blocks/vr/edit.js new file mode 100644 index 0000000000000..9ee1dc35219c3 --- /dev/null +++ b/extensions/blocks/vr/edit.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { Placeholder, SelectControl, TextControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import VRImageSave from './save'; +import { __ } from '../../utils/i18n'; + +export default class VRImageEdit extends Component { + onChangeUrl = value => void this.props.setAttributes( { url: value.trim() } ); + onChangeView = value => void this.props.setAttributes( { view: value } ); + + render() { + const { attributes, className } = this.props; + + if ( attributes.url && attributes.view ) { + return <VRImageSave attributes={ attributes } className={ className } />; + } + + return ( + <Placeholder + key="placeholder" + icon="format-image" + label={ __( 'VR Image' ) } + className={ className } + > + <TextControl + type="url" + label={ __( 'Enter URL to VR image' ) } + value={ attributes.url } + onChange={ this.onChangeUrl } + /> + <SelectControl + label={ __( 'View Type' ) } + disabled={ ! attributes.url } + value={ attributes.view } + onChange={ this.onChangeView } + options={ [ + { label: '', value: '' }, + { label: __( '360°' ), value: '360' }, + { label: __( 'Cinema' ), value: 'cinema' }, + ] } + /> + </Placeholder> + ); + } +} diff --git a/extensions/blocks/vr/editor.js b/extensions/blocks/vr/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/vr/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/vr/editor.scss b/extensions/blocks/vr/editor.scss new file mode 100644 index 0000000000000..3f628c193b59c --- /dev/null +++ b/extensions/blocks/vr/editor.scss @@ -0,0 +1,12 @@ + +.wp-block-jetpack-vr { + position: relative; + max-width: 525px; + margin-left: auto; + margin-right: auto; + overflow: hidden; + + .components-placeholder__fieldset { + justify-content: space-around; + } +} diff --git a/extensions/blocks/vr/index.js b/extensions/blocks/vr/index.js new file mode 100644 index 0000000000000..7e02cfe2fefbf --- /dev/null +++ b/extensions/blocks/vr/index.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import './editor.scss'; +import VRImageEdit from './edit'; +import VRImageSave from './save'; +import { __ } from '../../utils/i18n'; + +export const name = 'vr'; + +export const settings = { + title: __( 'VR Image' ), + description: __( 'Embed 360° photos and Virtual Reality (VR) content' ), + icon: 'embed-photo', + category: 'jetpack', + keywords: [ __( 'vr' ), __( 'panorama' ), __( '360' ) ], + supports: { + html: false, + }, + attributes: { + url: { + type: 'string', + }, + view: { + type: 'string', + }, + }, + edit: VRImageEdit, + save: VRImageSave, +}; diff --git a/extensions/blocks/vr/save.js b/extensions/blocks/vr/save.js new file mode 100644 index 0000000000000..ab341f798ed77 --- /dev/null +++ b/extensions/blocks/vr/save.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +export default ( { attributes: { url, view }, className } ) => ( + <div className={ className }> + <iframe + title={ __( 'VR Image' ) } + allowFullScreen="true" + frameBorder="0" + width="100%" + height="300" + src={ addQueryArgs( 'https://vr.me.sh/view/', { url, view } ) } + /> + </div> +); diff --git a/extensions/blocks/wordads/constants.js b/extensions/blocks/wordads/constants.js new file mode 100644 index 0000000000000..b0d21b8fae9c5 --- /dev/null +++ b/extensions/blocks/wordads/constants.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; + +export const DEFAULT_FORMAT = 'mrec'; +export const AD_FORMATS = [ + { + height: 250, + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-7-2h2V7h-4v2h2z" /> + </SVG> + ), + name: __( 'Rectangle 300x250' ), + tag: DEFAULT_FORMAT, + width: 300, + editorPadding: 30, + }, + { + height: 90, + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-4-4h-4v-2h2c1.1 0 2-.89 2-2V9c0-1.11-.9-2-2-2H9v2h4v2h-2c-1.1 0-2 .89-2 2v4h6v-2z" /> + </SVG> + ), + name: __( 'Leaderboard 728x90' ), + tag: 'leaderboard', + width: 728, + editorPadding: 60, + }, + { + height: 50, + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-4-4v-1.5c0-.83-.67-1.5-1.5-1.5.83 0 1.5-.67 1.5-1.5V9c0-1.11-.9-2-2-2H9v2h4v2h-2v2h2v2H9v2h4c1.1 0 2-.89 2-2z" /> + </SVG> + ), + name: __( 'Mobile Leaderboard 320x50' ), + tag: 'mobile_leaderboard', + width: 320, + editorPadding: 100, + }, + { + height: 600, + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M.04 0h24v24h-24V0z" /> + <Path d="M19.04 3h-14c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16h-14V5h14v14zm-6-2h2V7h-2v4h-2V7h-2v6h4z" /> + </SVG> + ), + name: __( 'Wide Skyscraper 160x600' ), + tag: 'wideskyscraper', + width: 160, + editorPadding: 30, + }, +]; diff --git a/extensions/blocks/wordads/edit.js b/extensions/blocks/wordads/edit.js new file mode 100644 index 0000000000000..8bb8a74a088f1 --- /dev/null +++ b/extensions/blocks/wordads/edit.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { __ } from '../../utils/i18n'; +import { BlockControls } from '@wordpress/editor'; +import { Component, Fragment } from '@wordpress/element'; +import { Placeholder, ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import FormatPicker from './format-picker'; +import { AD_FORMATS } from './constants'; +import { icon, title } from './'; + +import './editor.scss'; + +class WordAdsEdit extends Component { + handleHideMobileChange = hideMobile => { + this.props.setAttributes( { hideMobile: !! hideMobile } ); + }; + + render() { + const { attributes, setAttributes } = this.props; + const { format, hideMobile } = attributes; + const selectedFormatObject = AD_FORMATS.filter( ( { tag } ) => tag === format )[ 0 ]; + + return ( + <Fragment> + <BlockControls> + <FormatPicker + value={ format } + onChange={ nextFormat => setAttributes( { format: nextFormat } ) } + /> + </BlockControls> + <div className={ `wp-block-jetpack-wordads jetpack-wordads-${ format }` }> + <div + className="jetpack-wordads__ad" + style={ { + width: selectedFormatObject.width, + height: selectedFormatObject.height + selectedFormatObject.editorPadding, + } } + > + <Placeholder icon={ icon } label={ title } /> + <ToggleControl + checked={ Boolean( hideMobile ) } + label={ __( 'Hide ad on mobile views' ) } + onChange={ this.handleHideMobileChange } + /> + </div> + </div> + </Fragment> + ); + } +} +export default WordAdsEdit; diff --git a/extensions/blocks/wordads/editor.js b/extensions/blocks/wordads/editor.js new file mode 100644 index 0000000000000..babee275335fe --- /dev/null +++ b/extensions/blocks/wordads/editor.js @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import registerJetpackBlock from '../../utils/register-jetpack-block'; +import { name, settings } from '.'; + +registerJetpackBlock( name, settings ); diff --git a/extensions/blocks/wordads/editor.scss b/extensions/blocks/wordads/editor.scss new file mode 100644 index 0000000000000..a7d08227a4dd8 --- /dev/null +++ b/extensions/blocks/wordads/editor.scss @@ -0,0 +1,54 @@ +.wp-block-jetpack-wordads { + background: var( --color-white ); +} + +[data-type='jetpack/wordads'][data-align='center'] .jetpack-wordads__ad { + margin: 0 auto; +} + +.jetpack-wordads__ad { + display: flex; + overflow: hidden; + flex-direction: column; + max-width: 100%; + + .components-placeholder { + flex-grow: 2; + } + + .components-toggle-control__label { + line-height: 1.4em; + } + + .components-base-control__field { + padding: 7px; + } +} + +.jetpack-wordads-leaderboard .components-placeholder { + min-height: 90px; +} + +.jetpack-wordads-mobile_leaderboard .components-placeholder { + min-height: 72px; +} + +.wp-block-jetpack-wordads__format-picker { + $active-item-outline-width: 2px; + + // @TODO replace with Gutenberg variables + $dark-gray-500: #555d66; + $dark-gray-900: #191e23; + + padding: 7px; + + // Leave space between elements for active state styling + .components-menu-item__button + .components-menu-item__button { + margin-top: $active-item-outline-width; + } + + .components-menu-item__button.is-active { + color: $dark-gray-900; + box-shadow: 0 0 0 $active-item-outline-width $dark-gray-500 !important; + } +} diff --git a/extensions/blocks/wordads/format-picker.js b/extensions/blocks/wordads/format-picker.js new file mode 100644 index 0000000000000..3f113f0e9c299 --- /dev/null +++ b/extensions/blocks/wordads/format-picker.js @@ -0,0 +1,59 @@ +/** + * External Dependencies + */ +import { Dropdown, MenuItem, NavigableMenu, Path, SVG, Toolbar } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import { AD_FORMATS } from './constants'; + +const label = __( 'Pick an ad format' ); + +export default function FormatPicker( { value, onChange } ) { + return ( + <Dropdown + position="bottom right" + renderToggle={ ( { onToggle, isOpen } ) => { + return ( + <Toolbar + controls={ [ + { + icon: ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M1 9h2V7H1v2zm0 4h2v-2H1v2zm0-8h2V3c-1.1 0-2 .9-2 2zm8 16h2v-2H9v2zm-8-4h2v-2H1v2zm2 4v-2H1c0 1.1.9 2 2 2zM21 3h-8v6h10V5c0-1.1-.9-2-2-2zm0 14h2v-2h-2v2zM9 5h2V3H9v2zM5 21h2v-2H5v2zM5 5h2V3H5v2zm16 16c1.1 0 2-.9 2-2h-2v2zm0-8h2v-2h-2v2zm-8 8h2v-2h-2v2zm4 0h2v-2h-2v2z" /> + </SVG> + ), + title: label, + onClick: onToggle, + extraProps: { 'aria-expanded': isOpen }, + className: 'wp-block-jetpack-wordads__format-picker-icon', + }, + ] } + /> + ); + } } + renderContent={ ( { onClose } ) => ( + <NavigableMenu className="wp-block-jetpack-wordads__format-picker"> + { AD_FORMATS.map( ( { tag, name, icon } ) => ( + <MenuItem + className={ tag === value ? 'is-active' : undefined } + icon={ icon } + isSelected={ tag === value } + key={ tag } + onClick={ () => { + onChange( tag ); + onClose(); + } } + role="menuitemcheckbox" + > + { name } + </MenuItem> + ) ) } + </NavigableMenu> + ) } + /> + ); +} diff --git a/extensions/blocks/wordads/index.js b/extensions/blocks/wordads/index.js new file mode 100644 index 0000000000000..9a96630d18060 --- /dev/null +++ b/extensions/blocks/wordads/index.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { ExternalLink, Path, SVG } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { __ } from '../../utils/i18n'; +import edit from './edit'; +import { DEFAULT_FORMAT } from './constants'; + +export const name = 'wordads'; +export const title = __( 'Ad' ); + +export const icon = ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + <Path d="M12,8H4A2,2 0 0,0 2,10V14A2,2 0 0,0 4,16H5V20A1,1 0 0,0 6,21H8A1,1 0 0,0 9,20V16H12L17,20V4L12,8M15,15.6L13,14H4V10H13L15,8.4V15.6M21.5,12C21.5,13.71 20.54,15.26 19,16V8C20.53,8.75 21.5,10.3 21.5,12Z" /> + </SVG> +); + +export const settings = { + title, + + description: ( + <Fragment> + <p>{ __( 'Earn income by adding high quality ads to your post' ) }</p> + <ExternalLink href="https://wordads.co/">{ __( 'Learn all about WordAds' ) }</ExternalLink> + </Fragment> + ), + + icon, + attributes: { + align: { + type: 'string', + default: 'center', + }, + format: { + type: 'string', + default: DEFAULT_FORMAT, + }, + hideMobile: { + type: 'boolean', + default: false, + }, + }, + + category: 'jetpack', + + keywords: [ __( 'ads' ), 'WordAds', __( 'Advertisement' ) ], + + supports: { + align: [ 'left', 'center', 'right' ], + alignWide: false, + className: false, + customClassName: false, + html: false, + reusable: false, + }, + edit, + save: () => null, +}; diff --git a/extensions/shared/README.md b/extensions/shared/README.md new file mode 100644 index 0000000000000..25a49ce1ba42c --- /dev/null +++ b/extensions/shared/README.md @@ -0,0 +1,3 @@ +# Jetpack Block Editor Extensions: Shared + +The shared directory is meant to contain components, scripts, and stylesheets that are used in multiple blocks. diff --git a/extensions/shared/frontend-management.js b/extensions/shared/frontend-management.js new file mode 100644 index 0000000000000..dbb830e6e816e --- /dev/null +++ b/extensions/shared/frontend-management.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { assign, kebabCase } from 'lodash'; +import { createElement, render } from '@wordpress/element'; + +export class FrontendManagement { + blockIterator( rootNode, blocks ) { + blocks.forEach( block => { + this.initializeFrontendReactBlocks( block.component, block.options, rootNode ); + } ); + } + initializeFrontendReactBlocks( component, options = {}, rootNode ) { + const { attributes, name, prefix } = options.settings; + const { selector } = options; + const fullName = prefix && prefix.length ? `${ prefix }/${ name }` : name; + const blockClass = `.wp-block-${ fullName.replace( '/', '-' ) }`; + + const blockNodeList = rootNode.querySelectorAll( blockClass ); + for ( const node of blockNodeList ) { + const data = this.extractAttributesFromContainer( node, attributes ); + assign( data, options.props ); + const children = this.extractChildrenFromContainer( node ); + const el = createElement( component, data, children ); + render( el, selector ? node.querySelector( selector ) : node ); + } + } + extractAttributesFromContainer( node, attributes ) { + const data = {}; + for ( const name in attributes ) { + const attribute = attributes[ name ]; + const dataAttributeName = 'data-' + kebabCase( name ); + data[ name ] = node.getAttribute( dataAttributeName ); + if ( attribute.type === 'boolean' ) { + data[ name ] = data[ name ] === 'false' ? false : !! data[ name ]; + } + if ( attribute.type === 'array' || attribute.type === 'object' ) { + try { + data[ name ] = JSON.parse( data[ name ] ); + } catch ( e ) { + // console.log( 'Error decoding JSON data for field ' + name, e); + data[ name ] = null; + } + } + } + return data; + } + extractChildrenFromContainer( node ) { + const children = [ ...node.childNodes ]; + return children.map( child => { + const attr = {}; + for ( let i = 0; i < child.attributes.length; i++ ) { + const attribute = child.attributes[ i ]; + attr[ attribute.nodeName ] = attribute.nodeValue; + } + attr.dangerouslySetInnerHTML = { + __html: child.innerHTML, + }; + return createElement( child.tagName.toLowerCase(), attr ); + } ); + } +} + +export default FrontendManagement; diff --git a/extensions/shared/get-site-fragment.js b/extensions/shared/get-site-fragment.js new file mode 100644 index 0000000000000..6d141e4e70ce7 --- /dev/null +++ b/extensions/shared/get-site-fragment.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * Internal dependencies + */ +// FIXME: REMOVE! +import { getSiteFragment as getCalypsoSiteFragment } from '../../../../client/lib/route/path'; + +/** + * Returns the site fragment in the environment we're running Gutenberg in. + * + * @returns {?Number|String} Site fragment (ID or slug); null for WP.org wp-admin. + */ +export default function getSiteFragment() { + // WP.com wp-admin exposes the site ID in window._currentSiteId + if ( window && window._currentSiteId ) { + return window._currentSiteId; + } + + // FIXME: REMOVE THIS BLOCK! vvv + // Calypso will contain a site slug or ID in the site fragment. + // WP.org will contain either `post` or `post-new.php`. + const siteFragment = getCalypsoSiteFragment( window.location.pathname ); + if ( ! includes( [ 'post.php', 'post-new.php' ], siteFragment ) ) { + return siteFragment || null; + } + // FIXME: REMOVE TO HERE ^^^ + + // Gutenberg in Jetpack adds a site fragment in the initial state + if ( + window && + window.Jetpack_Editor_Initial_State && + window.Jetpack_Editor_Initial_State.siteFragment + ) { + return window.Jetpack_Editor_Initial_State.siteFragment; + } + + return null; +} diff --git a/extensions/shared/help-message.js b/extensions/shared/help-message.js new file mode 100644 index 0000000000000..4c3b7eb78ab21 --- /dev/null +++ b/extensions/shared/help-message.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import GridiconNoticeOutline from 'gridicons/dist/notice-outline'; +import './help-message.scss'; + +export default ( { children = null, isError = false, ...props } ) => { + const classes = classNames( 'help-message', { + 'help-message-is-error': isError, + } ); + + return ( + children && ( + <div className={ classes } { ...props }> + { isError && <GridiconNoticeOutline size="24" /> } + <span>{ children }</span> + </div> + ) + ); +}; diff --git a/extensions/shared/help-message.scss b/extensions/shared/help-message.scss new file mode 100644 index 0000000000000..b8ea026fa4560 --- /dev/null +++ b/extensions/shared/help-message.scss @@ -0,0 +1,21 @@ + +.help-message { + display: flex; + font-size: 13px; + line-height: 1.4em; + margin-bottom: 1em; + margin-top: -0.5em; + svg { + margin-right: 5px; + min-width: 24px; + } + > span { + margin-top: 2px; + } + &.help-message-is-error { + color: var( --color-error ); + svg { + fill: var( --color-error ); + } + } +} diff --git a/extensions/shared/jetpack-logo.js b/extensions/shared/jetpack-logo.js new file mode 100644 index 0000000000000..0d13127b9689f --- /dev/null +++ b/extensions/shared/jetpack-logo.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { Path, Polygon, SVG } from '@wordpress/components'; +import classNames from 'classnames'; + +export default ( { size = 24, className } ) => ( + <SVG + className={ classNames( 'jetpack-logo', className ) } + width={ size } + height={ size } + viewBox="0 0 32 32" + > + <Path + className="jetpack-logo__icon-circle" + fill="#00be28" + d="M16,0C7.2,0,0,7.2,0,16s7.2,16,16,16s16-7.2,16-16S24.8,0,16,0z" + /> + <Polygon className="jetpack-logo__icon-triangle" fill="#fff" points="15,19 7,19 15,3 " /> + <Polygon className="jetpack-logo__icon-triangle" fill="#fff" points="17,29 17,13 25,13 " /> + </SVG> +); diff --git a/extensions/shared/jetpack-plugin-sidebar.js b/extensions/shared/jetpack-plugin-sidebar.js new file mode 100644 index 0000000000000..b3cfd3d789a39 --- /dev/null +++ b/extensions/shared/jetpack-plugin-sidebar.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { createSlotFill } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; +import { PluginSidebar, PluginSidebarMoreMenuItem } from '@wordpress/edit-post'; +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import './jetpack-plugin-sidebar.scss'; +import JetpackLogo from './jetpack-logo'; + +const { Fill, Slot } = createSlotFill( 'JetpackPluginSidebar' ); + +const JetpackPluginSidebar = ( { children } ) => <Fill>{ children }</Fill>; + +JetpackPluginSidebar.Slot = () => ( + <Slot> + { fills => { + if ( ! fills.length ) { + return null; + } + + return ( + <Fragment> + <PluginSidebarMoreMenuItem target="jetpack" icon={ <JetpackLogo /> }> + Jetpack + </PluginSidebarMoreMenuItem> + <PluginSidebar name="jetpack" title="Jetpack" icon={ <JetpackLogo /> }> + { fills } + </PluginSidebar> + </Fragment> + ); + } } + </Slot> +); + +registerPlugin( 'jetpack-sidebar', { + render: () => <JetpackPluginSidebar.Slot />, +} ); + +export default JetpackPluginSidebar; diff --git a/extensions/shared/jetpack-plugin-sidebar.scss b/extensions/shared/jetpack-plugin-sidebar.scss new file mode 100644 index 0000000000000..494cba0f8e582 --- /dev/null +++ b/extensions/shared/jetpack-plugin-sidebar.scss @@ -0,0 +1,42 @@ +.edit-post-pinned-plugins, +.edit-post-more-menu__content { + .components-icon-button { + .jetpack-logo { + width: 20px; + height: 20px; + } + } +} + +.edit-post-more-menu__content { + .components-icon-button { + .jetpack-logo { + margin-right: 4px; + } + } +} + +.edit-post-pinned-plugins { + .components-icon-button { + &:not( .is-toggled ), + &:hover, + &.is-toggled, + &.is-toggled:hover { + .jetpack-logo, + .jetpack-logo__icon-circle, + .jetpack-logo__icon-triangle { + stroke: none !important; + } + + .jetpack-logo__icon-circle { + // CSS colour variables are not available in Gutenberg extensions when built by SDK + fill: $green-jetpack !important; + } + + .jetpack-logo__icon-triangle { + // CSS colour variables are not available in Gutenberg extensions when built by SDK + fill: var( --color-white ) !important; + } + } + } +} diff --git a/extensions/shared/test/get-site-fragment.js b/extensions/shared/test/get-site-fragment.js new file mode 100644 index 0000000000000..ec51761bde343 --- /dev/null +++ b/extensions/shared/test/get-site-fragment.js @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import getSiteFragment from '../get-site-fragment'; + +describe( 'getSiteFragment()', () => { + let beforeWindow; + + beforeAll( () => { + beforeWindow = global.window; + global.window = { + location: {}, + }; + } ); + + afterAll( () => { + global.window = beforeWindow; + } ); + + test( 'should return null by default', () => { + window.location.pathname = '/'; + expect( getSiteFragment() ).toBeNull(); + } ); + + test( 'should return null when editing a post in wp-admin', () => { + window.location.pathname = '/wp-admin/post.php'; + expect( getSiteFragment() ).toBeNull(); + } ); + + test( 'should return null when starting a new post in wp-admin', () => { + window.location.pathname = '/wp-admin/post-new.php'; + expect( getSiteFragment() ).toBeNull(); + } ); + + test( 'should return site fragment when starting a post in calypso', () => { + window.location.pathname = '/block-editor/post/yourjetpack.blog'; + expect( getSiteFragment() ).toBe( 'yourjetpack.blog' ); + } ); + + test( 'should return site fragment when editing a post in calypso', () => { + window.location.pathname = '/block-editor/post/yourjetpack.blog/123'; + expect( getSiteFragment() ).toBe( 'yourjetpack.blog' ); + } ); + + test( 'should return site ID when _currentSiteId is exposed', () => { + window.location.pathname = '/block-editor/post/yourjetpack.blog/123'; + window._currentSiteId = 12345678; + expect( getSiteFragment() ).toBe( 12345678 ); + } ); + + test( 'should return site slug when editing a post in Gutenberg in WP-Admin', () => { + delete window._currentSiteId; + window.Jetpack_Editor_Initial_State = { + siteFragment: 'yourjetpack.blog', + }; + expect( getSiteFragment() ).toBe( 'yourjetpack.blog' ); + } ); +} ); diff --git a/extensions/utils/README.md b/extensions/utils/README.md new file mode 100644 index 0000000000000..54faf077c1658 --- /dev/null +++ b/extensions/utils/README.md @@ -0,0 +1,20 @@ +# Jetpack Block Editor Extensions: Utils + +This folder contains files that are not automatically bundled, +but included on an individual basis instead. + +## getJetpackData() + +Gets a list of available blocks, and jetpack's connection status. +On a Jetpack site, there are special cases when a block is considered unavailable. +For example, the site may not have the required module, they may not have the required plan, +or the site admin may have filtered out the use of a certain block. + +## registerJetpackBlock() + +This util will only register a block if it meets the availability requirements described above. + + +## registerJetpackPlugin() + +This util will only register a Gutenberg plugin if it meets the availability requirements described above. diff --git a/extensions/utils/block-styles.js b/extensions/utils/block-styles.js new file mode 100644 index 0000000000000..5a278356fc6d3 --- /dev/null +++ b/extensions/utils/block-styles.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import TokenList from '@wordpress/token-list'; +import { find } from 'lodash'; + +/** + * Returns the active style from the given className. + * + * From @link https://github.com/WordPress/gutenberg/blob/ddac4f3cf8fd311169c7e125411343a437bdbb5a/packages/editor/src/components/block-styles/index.js#L20-L42 + * + * @param {Array} styles Block style variations. + * @param {string} className Class name + * + * @return {Object?} The active style. + */ +function getActiveStyle( styles, className ) { + for ( const style of new TokenList( className ).values() ) { + if ( style.indexOf( 'is-style-' ) === -1 ) { + continue; + } + + const potentialStyleName = style.substring( 9 ); + const activeStyle = find( styles, { name: potentialStyleName } ); + if ( activeStyle ) { + return activeStyle; + } + } + + return find( styles, 'isDefault' ); +} + +export function getActiveStyleName( styles, className ) { + const style = getActiveStyle( styles, className ); + return style ? style.name : null; +} + +export function getDefaultStyleClass( styles ) { + const defaultStyle = find( styles, 'isDefault' ); + return defaultStyle ? `is-style-${ defaultStyle.name }` : null; +} + +/** + * Checks if className has a class selector starting with `is-style-` + * Does not check validity of found style. + * + * @param {String} classNames Optional. Space separated classNames. Defaults to ''. + * @return {Boolean} true if `classNames` has a Gutenberg style class + */ +export function hasStyleClass( classNames = '' ) { + return classNames.split( ' ' ).some( className => className.startsWith( 'is-style-' ) ); +} diff --git a/extensions/utils/clipboard-input.js b/extensions/utils/clipboard-input.js new file mode 100644 index 0000000000000..44250540f6c9a --- /dev/null +++ b/extensions/utils/clipboard-input.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { ClipboardButton, TextControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { __, _x } from './i18n'; +import './clipboard-input.scss'; + +class ClipboardInput extends Component { + state = { + hasCopied: false, + }; + + onCopy = () => this.setState( { hasCopied: true } ); + + onFinishCopy = () => this.setState( { hasCopied: false } ); + + onFocus = event => event.target.select(); + + render() { + const { link } = this.props; + const { hasCopied } = this.state; + + if ( ! link ) { + return null; + } + + return ( + <div className="jetpack-clipboard-input"> + <TextControl readOnly onFocus={ this.onFocus } value={ link } /> + <ClipboardButton + isDefault + onCopy={ this.onCopy } + onFinishCopy={ this.onFinishCopy } + text={ link } + > + { hasCopied ? __( 'Copied!' ) : _x( 'Copy', 'verb' ) } + </ClipboardButton> + </div> + ); + } +} + +export default ClipboardInput; diff --git a/extensions/utils/clipboard-input.scss b/extensions/utils/clipboard-input.scss new file mode 100644 index 0000000000000..d0118a3efc72c --- /dev/null +++ b/extensions/utils/clipboard-input.scss @@ -0,0 +1,7 @@ +.jetpack-clipboard-input { + display: flex; + + .components-clipboard-button { + margin: 2px 0 0 6px; + } +} diff --git a/extensions/utils/get-jetpack-data.js b/extensions/utils/get-jetpack-data.js new file mode 100644 index 0000000000000..6871383a6dc0e --- /dev/null +++ b/extensions/utils/get-jetpack-data.js @@ -0,0 +1,10 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export const JETPACK_DATA_PATH = 'Jetpack_Editor_Initial_State'; + +export default function getJetpackData() { + return get( 'object' === typeof window ? window : null, [ JETPACK_DATA_PATH ], null ); +} diff --git a/extensions/utils/get-jetpack-extension-availability.js b/extensions/utils/get-jetpack-extension-availability.js new file mode 100644 index 0000000000000..a3636f1c91289 --- /dev/null +++ b/extensions/utils/get-jetpack-extension-availability.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { get, includes } from 'lodash'; + +/** + * Internal dependencies + */ +import extensionSlugsJson from '../preset/index.json'; +import getJetpackData from './get-jetpack-data'; + +/** + * Return whether a Jetpack Gutenberg extension is available or not. + * + * Defaults to `false` for production blocks, and to `true` for beta blocks. + * This is to make it easier for folks to write their first block without needing + * to touch the server side. + * + * @param {string} name The extension's name (without the `jetpack/` prefix) + * @returns {object} Object indicating if the extension is available (property `available`) and the reason why it is + * unavailable (property `unavailable_reason`). + */ +export default function getJetpackExtensionAvailability( name ) { + const data = getJetpackData(); + const defaultAvailability = includes( extensionSlugsJson.beta, name ); + const available = get( data, [ 'available_blocks', name, 'available' ], defaultAvailability ); + const unavailableReason = get( + data, + [ 'available_blocks', name, 'unavailable_reason' ], + 'unknown' + ); + + return { + available, + ...( ! available && { unavailableReason } ), + }; +} diff --git a/extensions/utils/i18n.js b/extensions/utils/i18n.js new file mode 100644 index 0000000000000..0759252b02b17 --- /dev/null +++ b/extensions/utils/i18n.js @@ -0,0 +1,103 @@ +/** + * This contains a set of wrappers for all the @wordpress/i18n localization functions. + * Each of the wrappers has the same signature like the original corresponding function, + * but without the textdomain at the end. + * + * The wrappers are necessary because we'd like to reuse i18n-calypso provided translations + * for our Gutenberg blocks, but we'd also like to not include i18n-calypso in block bundles. + * Instead, we use @wordpress/i18n, but it requires a textdomain in order to know where + * to look for the translations. + * + * In the same time, we use those blocks in Jetpack, and in order to be able to index the + * translations there without issues, the localization function calls in the source code + * must not contain a textdomain. + */ + +/** + * External dependencies + */ +import { __ as wpI18n__, _n as wpI18n_n, _x as wpI18n_x, _nx as wpI18n_nx } from '@wordpress/i18n'; + +/** + * Module variables + */ +const TEXTDOMAIN = 'jetpack'; + +/** + * Add a textdomain to arguments of a localization function call. + * + * @param {Array} originalArgs Arguments that the localization function was called with. + * + * @return {Array} Arguments, with textdomain added as the last one. + */ +const addTextdomain = originalArgs => { + const args = [ ...originalArgs ]; + args.push( TEXTDOMAIN ); + return args; +}; + +/** + * Retrieve the translation of text. + * + * @see https://developer.wordpress.org/reference/functions/__/ + * + * @param {string} text Text to translate. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} Translated text. + */ +export function __() { + return wpI18n__( ...addTextdomain( arguments ) ); +} + +/** + * Translates and retrieves the singular or plural form based on the supplied + * number. + * + * @see https://developer.wordpress.org/reference/functions/_n/ + * + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {number} number The number to compare against to use either the + * singular or plural form. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} The translated singular or plural form. + */ +export function _n() { + return wpI18n_n( ...addTextdomain( arguments ) ); +} + +/** + * Retrieve translated string with gettext context. + * + * @see https://developer.wordpress.org/reference/functions/_x/ + * + * @param {string} text Text to translate. + * @param {string} context Context information for the translators. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} Translated context string without pipe. + */ +export function _x() { + return wpI18n_x( ...addTextdomain( arguments ) ); +} + +/** + * Translates and retrieves the singular or plural form based on the supplied + * number, with gettext context. + * + * @see https://developer.wordpress.org/reference/functions/_nx/ + * + * @param {string} single The text to be used if the number is singular. + * @param {string} plural The text to be used if the number is plural. + * @param {number} number The number to compare against to use either the + * singular or plural form. + * @param {string} context Context information for the translators. + * @param {?string} domain Domain to retrieve the translated text. + * + * @return {string} The translated singular or plural form. + */ +export function _nx() { + return wpI18n_nx( ...addTextdomain( arguments ) ); +} diff --git a/extensions/utils/index.js b/extensions/utils/index.js new file mode 100644 index 0000000000000..13e2e308b4974 --- /dev/null +++ b/extensions/utils/index.js @@ -0,0 +1 @@ +export { getActiveStyleName, getDefaultStyleClass, hasStyleClass } from './block-styles'; diff --git a/extensions/utils/register-jetpack-block.js b/extensions/utils/register-jetpack-block.js new file mode 100644 index 0000000000000..be72fe373ab9e --- /dev/null +++ b/extensions/utils/register-jetpack-block.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import getJetpackExtensionAvailability from './get-jetpack-extension-availability'; + +/** + * Registers a gutenberg block if the availability requirements are met. + * + * @param {string} name The block's name. + * @param {object} settings The block's settings. + * @param {object} childBlocks The block's child blocks. + * @returns {object|false} Either false if the block is not available, or the results of `registerBlockType` + */ +export default function registerJetpackBlock( name, settings, childBlocks = [] ) { + const { available, unavailableReason } = getJetpackExtensionAvailability( name ); + const unavailable = ! available; + + if ( unavailable ) { + if ( 'production' !== process.env.NODE_ENV ) { + // eslint-disable-next-line no-console + console.warn( + `Block ${ name } couldn't be registered because it is unavailable (${ unavailableReason }).` + ); + } + return false; + } + + const result = registerBlockType( `jetpack/${ name }`, settings ); + + // Register child blocks. Using `registerBlockType()` directly avoids availability checks -- if + // their parent is available, we register them all, without checking for their individual availability. + childBlocks.forEach( childBlock => + registerBlockType( `jetpack/${ childBlock.name }`, childBlock.settings ) + ); + + return result; +} diff --git a/extensions/utils/register-jetpack-plugin.js b/extensions/utils/register-jetpack-plugin.js new file mode 100644 index 0000000000000..8d891462b9ef5 --- /dev/null +++ b/extensions/utils/register-jetpack-plugin.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import getJetpackExtensionAvailability from './get-jetpack-extension-availability'; + +/** + * Registers a Gutenberg block if the availability requirements are met. + * + * @param {string} name The plugin's name + * @param {object} settings The plugin's settings. + * @returns {object|false} Either false if the plugin is not available, or the results of `registerPlugin` + */ +export default function registerJetpackPlugin( name, settings ) { + const { available, unavailableReason } = getJetpackExtensionAvailability( name ); + const unavailable = ! available; + + if ( unavailable ) { + if ( 'production' !== process.env.NODE_ENV ) { + // eslint-disable-next-line no-console + console.warn( + `Plugin ${ name } couldn't be registered because it is unavailable (${ unavailableReason }).` + ); + } + return false; + } + + return registerPlugin( `jetpack-${ name }`, settings ); +} diff --git a/extensions/utils/render-material-icon.js b/extensions/utils/render-material-icon.js new file mode 100644 index 0000000000000..82d245915631a --- /dev/null +++ b/extensions/utils/render-material-icon.js @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +const renderMaterialIcon = svg => ( + <SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <Path fill="none" d="M0 0h24v24H0V0z" /> + { svg } + </SVG> +); + +export default renderMaterialIcon; diff --git a/extensions/utils/simple-input.js b/extensions/utils/simple-input.js new file mode 100644 index 0000000000000..c55a5a6f6670b --- /dev/null +++ b/extensions/utils/simple-input.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { PlainText } from '@wordpress/editor'; + +const simpleInput = ( type, props, label, view, onChange ) => { + const { isSelected } = props; + const value = props.attributes[ type ]; + return ( + <div + className={ isSelected ? `jetpack-${ type }-block is-selected` : `jetpack-${ type }-block` } + > + { ! isSelected && value !== '' && view( props ) } + { ( isSelected || value === '' ) && ( + <PlainText + value={ value } + placeholder={ label } + aria-label={ label } + onChange={ onChange } + /> + ) } + </div> + ); +}; + +export default simpleInput; diff --git a/extensions/utils/submit-button.js b/extensions/utils/submit-button.js new file mode 100644 index 0000000000000..bc3d090328482 --- /dev/null +++ b/extensions/utils/submit-button.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { Component, Fragment } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withFallbackStyles } from '@wordpress/components'; +import { + InspectorControls, + PanelColorSettings, + ContrastChecker, + RichText, + withColors, +} from '@wordpress/editor'; +import { isEqual, get } from 'lodash'; + +/** + * Internal dependencies + */ +import { __ } from './i18n'; + +const { getComputedStyle } = window; + +const applyFallbackStyles = withFallbackStyles( ( node, ownProps ) => { + const { textButtonColor, backgroundButtonColor } = ownProps; + const backgroundColorValue = backgroundButtonColor && backgroundButtonColor.color; + const textColorValue = textButtonColor && textButtonColor.color; + //avoid the use of querySelector if textColor color is known and verify if node is available. + + let textNode; + let button; + + if ( ! textColorValue && node ) { + textNode = node.querySelector( '[contenteditable="true"]' ); + } + + if ( node.querySelector( '.wp-block-button__link' ) ) { + button = node.querySelector( '.wp-block-button__link' ); + } else { + button = node; + } + + let fallbackBackgroundColor; + let fallbackTextColor; + + if ( node ) { + fallbackBackgroundColor = getComputedStyle( button ).backgroundColor; + } + + if ( textNode ) { + fallbackTextColor = getComputedStyle( textNode ).color; + } + + return { + fallbackBackgroundColor: backgroundColorValue || fallbackBackgroundColor, + fallbackTextColor: textColorValue || fallbackTextColor, + }; +} ); + +class SubmitButton extends Component { + componentDidUpdate( prevProps ) { + if ( + ! isEqual( this.props.textButtonColor, prevProps.textButtonColor ) || + ! isEqual( this.props.backgroundButtonColor, prevProps.backgroundButtonColor ) + ) { + const buttonClasses = this.getButtonClasses(); + this.props.setAttributes( { submitButtonClasses: buttonClasses } ); + } + } + getButtonClasses() { + const { textButtonColor, backgroundButtonColor } = this.props; + const textClass = get( textButtonColor, 'class' ); + const backgroundClass = get( backgroundButtonColor, 'class' ); + return classnames( 'wp-block-button__link', { + 'has-text-color': textButtonColor, + [ textClass ]: textClass, + 'has-background': backgroundButtonColor, + [ backgroundClass ]: backgroundClass, + } ); + } + render() { + const { + attributes, + fallbackBackgroundColor, + fallbackTextColor, + setAttributes, + setBackgroundButtonColor, + setTextButtonColor, + } = this.props; + + const backgroundColor = attributes.customBackgroundButtonColor || fallbackBackgroundColor; + const color = attributes.customTextButtonColor || fallbackTextColor; + const buttonStyle = { border: 'none', backgroundColor, color }; + const buttonClasses = this.getButtonClasses(); + + return ( + <Fragment> + <div className="wp-block-button jetpack-submit-button"> + <RichText + placeholder={ __( 'Add text…' ) } + value={ attributes.submitButtonText } + onChange={ nextValue => setAttributes( { submitButtonText: nextValue } ) } + className={ buttonClasses } + style={ buttonStyle } + keepPlaceholderOnFocus + formattingControls={ [] } + /> + </div> + <InspectorControls> + <PanelColorSettings + title={ __( 'Button Color Settings' ) } + colorSettings={ [ + { + value: backgroundColor, + onChange: nextColour => { + setBackgroundButtonColor( nextColour ); + setAttributes( { customBackgroundButtonColor: nextColour } ); + }, + label: __( 'Background Color' ), + }, + { + value: color, + onChange: nextColour => { + setTextButtonColor( nextColour ); + setAttributes( { customTextButtonColor: nextColour } ); + }, + label: __( 'Text Color' ), + }, + ] } + /> + <ContrastChecker textColor={ color } backgroundColor={ backgroundColor } /> + </InspectorControls> + </Fragment> + ); + } +} + +export default compose( [ + withColors( 'backgroundButtonColor', { textButtonColor: 'color' } ), + applyFallbackStyles, +] )( SubmitButton ); diff --git a/extensions/utils/text-match-replace.js b/extensions/utils/text-match-replace.js new file mode 100644 index 0000000000000..b339719f5e1b7 --- /dev/null +++ b/extensions/utils/text-match-replace.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import { isRegExp, escapeRegExp, isString, flatten } from 'lodash'; + +/** + * Given a string, replace every substring that is matched by the `match` regex + * with the result of calling `fn` on matched substring. The result will be an + * array with all odd indexed elements containing the replacements. The primary + * use case is similar to using String.prototype.replace except for React. + * + * React will happily render an array as children of a react element, which + * makes this approach very useful for tasks like surrounding certain text + * within a string with react elements. + * + * Example: + * matchReplace( + * 'Emphasize all phone numbers like 884-555-4443.', + * /([\d|-]+)/g, + * (number, i) => <strong key={i}>{number}</strong> + * ); + * // => ['Emphasize all phone numbers like ', <strong>884-555-4443</strong>, '.' + * + * @param {string} text - The text that you want to replace + * @param {regexp|str} match Must contain a matching group + * @param {function} fn function that helps replace the matched text + * @return {array} An array of string or react components + */ +function replaceString( text, match, fn ) { + let curCharStart = 0; + let curCharLen = 0; + + if ( text === '' ) { + return text; + } else if ( ! text || ! isString( text ) ) { + throw new TypeError( 'First argument must be a string' ); + } + + let re = match; + + if ( ! isRegExp( re ) ) { + re = new RegExp( '(' + escapeRegExp( re ) + ')', 'gi' ); + } + + const result = text.split( re ); + // Apply fn to all odd elements + for ( let i = 1, length = result.length; i < length; i += 2 ) { + curCharLen = result[ i ].length; + curCharStart += result[ i - 1 ].length; + if ( result[ i ] ) { + result[ i ] = fn( result[ i ], i, curCharStart ); + } + curCharStart += curCharLen; + } + + return result; +} + +const textMatchReplace = ( source, match, fn ) => { + if ( ! Array.isArray( source ) ) source = [ source ]; + + return flatten( + source.map( x => { + return isString( x ) ? replaceString( x, match, fn ) : x; + } ) + ); +}; + +export default textMatchReplace; diff --git a/tools/build-release-branch.sh b/tools/build-release-branch.sh index b225559448fff..801a37e292c3a 100755 --- a/tools/build-release-branch.sh +++ b/tools/build-release-branch.sh @@ -1,4 +1,5 @@ #!/bin/bash + # This script can build a new set of release branches, or update an existing release branch. # It doesn't care which branch you're currently standing on. # @@ -16,54 +17,68 @@ # Exit the build in scary red text if error function exit_build { - echo -e "${RED}Something went wrong and the build has stopped. See error above for more details." - exit 1 + echo -e "${RED}Something went wrong and the build has stopped. See error above for more details." + exit 1 } trap 'exit_build' ERR # Instructions function usage { - echo "usage: $0 [-n new] [-u update <branchname>]" - echo " -n Create new release branches" - echo " -u Update existing release built branch" - echo " Can take an extra param that refers to an existing branch." - echo " Example: $0 -u master-stable" - echo " -h help" - exit 1 + echo "usage: $0 [-n new] [-u update <branchname>]" + echo " -n Create new release branches" + echo " -u Update existing release built branch" + echo " Can take an extra param that refers to an existing branch." + echo " Example: $0 -u master-stable" + echo " -h help" + exit 1 } # This creates a new .gitignore file based on master, but removes the items we need for release builds function create_release_gitignore { - # Copy .gitignore to temp file - mv .gitignore .gitignore-tmp - - # Create empty .gitignore - touch .gitignore - - # Add things to the new .gitignore file, stopping at the things we want to keep. - while IFS='' read -r line || [[ -n "$line" ]] - do - if [ "$line" == "## Things we will need in release branches" ] - then - break - fi - echo "$line" >> .gitignore - done < ".gitignore-tmp" - - # Add custom stuff to .gitignore release - echo "/_inc/client" >> .gitignore - - # Remove old .gitignore - rm .gitignore-tmp - - git commit .gitignore -m "updated .gitignore" + # Copy .gitignore to temp file + mv .gitignore .gitignore-tmp + + # Create empty .gitignore + touch .gitignore + + # Add things to the new .gitignore file, stopping at the things we want to keep. + while IFS='' read -r line || [[ -n "$line" ]]; do + if [ "$line" == "## Things we will need in release branches" ]; then + break + fi + echo "$line" >> .gitignore + done < ".gitignore-tmp" + + # Add custom stuff to .gitignore release + echo "/_inc/client" >> .gitignore + echo "/docker/" >> .gitignore + + # Needs to stay in sync with .svnignore and `create_new_release_branches` in this file. + echo "__snapshots__/" >> .gitignore + echo "/extensions/**/*.css" >> .gitignore + echo "/extensions/**/*.gif" >> .gitignore + echo "/extensions/**/*.jpeg" >> .gitignore + echo "/extensions/**/*.jpg" >> .gitignore + echo "/extensions/**/*.js" >> .gitignore + echo "/extensions/**/*.json" >> .gitignore + echo "/extensions/**/*.jsx" >> .gitignore + echo "/extensions/**/*.md" >> .gitignore + echo "/extensions/**/*.png" >> .gitignore + echo "/extensions/**/*.sass" >> .gitignore + echo "/extensions/**/*.scss" >> .gitignore + echo "/extensions/**/*.svg" >> .gitignore + + # Remove old .gitignore + rm .gitignore-tmp + + git commit .gitignore -m "updated .gitignore" } # Remove stuff from .svnignore for releases function modify_svnignore { - awk '!/.eslintrc.js/' .svnignore > temp && mv temp .svnignore - awk '!/.eslintignore/' .svnignore > temp && mv temp .svnignore - git commit .svnignore -m "Updated .svnignore" + awk '!/.eslintrc.js/' .svnignore > temp && mv temp .svnignore + awk '!/.eslintignore/' .svnignore > temp && mv temp .svnignore + git commit .svnignore -m "Updated .svnignore" } # This function will create a new set of release branches. @@ -71,62 +86,60 @@ function modify_svnignore { # These branches will be created off of master. function create_new_release_branches { - # Prompt for version number. - read -p "What version are you releasing? Please write in x.x syntax. Example: 4.9 - " version - - # Declare the new branch names. - NEW_UNBUILT_BRANCH="branch-$version" - NEW_BUILT_BRANCH="branch-$version-built" - - # Check if branch already exists, if not, create new branch named "branch-x.x" - if [[ -n $( git branch -r | grep "$NEW_UNBUILT_BRANCH" ) ]]; - then - echo "$NEW_UNBUILT_BRANCH already exists. Exiting..." - exit 1 - else - echo "" - echo "Creating new unbuilt branch $NEW_UNBUILT_BRANCH from current master branch..." - echo "" - # reset --hard to remote master in case they have local commits in their repo - git checkout master && git pull && git reset --hard origin/master - - # Create new branch, push to repo - git checkout -b $NEW_UNBUILT_BRANCH - - git push -u origin $NEW_UNBUILT_BRANCH - echo "" - echo "$NEW_UNBUILT_BRANCH created." - echo "" - # Verify you want a built version - read -n1 -p "Would you like to create a built version of $NEW_UNBUILT_BRANCH as new $NEW_BUILT_BRANCH? [y/N]" reply - if [[ 'y' == $reply || 'Y' == $reply ]] - then - # make sure we're still checked out on the right branch - git checkout $NEW_UNBUILT_BRANCH - - git checkout -b $NEW_BUILT_BRANCH - - # New .gitignore for release branches - echo "" - echo "Creating new .gitignore" - echo "" - create_release_gitignore - - # Remove stuff from svnignore - modify_svnignore - - git checkout $NEW_UNBUILT_BRANCH - - git push -u origin $NEW_BUILT_BRANCH - - # Script will continue on to actually build the plugin onto this new branch... - else - # Nothing left to do... - echo "" - echo "Ok, all done then." - exit 1 - fi - fi + # Prompt for version number. + read -p "What version are you releasing? Please write in x.x syntax. Example: 4.9 - " version + + # Declare the new branch names. + NEW_UNBUILT_BRANCH="branch-$version" + NEW_BUILT_BRANCH="branch-$version-built" + + # Check if branch already exists, if not, create new branch named "branch-x.x" + if [[ -n $( git branch -r | grep "$NEW_UNBUILT_BRANCH" ) ]]; then + echo "$NEW_UNBUILT_BRANCH already exists. Exiting..." + exit 1 + else + echo "" + echo "Creating new unbuilt branch $NEW_UNBUILT_BRANCH from current master branch..." + echo "" + # reset --hard to remote master in case they have local commits in their repo + git checkout master && git pull && git reset --hard origin/master + + # Create new branch, push to repo + git checkout -b $NEW_UNBUILT_BRANCH + + git push -u origin $NEW_UNBUILT_BRANCH + echo "" + echo "$NEW_UNBUILT_BRANCH created." + echo "" + # Verify you want a built version + read -n1 -p "Would you like to create a built version of $NEW_UNBUILT_BRANCH as new $NEW_BUILT_BRANCH? [y/N]" reply + if [[ 'y' == $reply || 'Y' == $reply ]]; then + # make sure we're still checked out on the right branch + git checkout $NEW_UNBUILT_BRANCH + + git checkout -b $NEW_BUILT_BRANCH + + # New .gitignore for release branches + echo "" + echo "Creating new .gitignore" + echo "" + create_release_gitignore + + # Remove stuff from svnignore + modify_svnignore + + git checkout $NEW_UNBUILT_BRANCH + + git push -u origin $NEW_BUILT_BRANCH + + # Script will continue on to actually build the plugin onto this new branch... + else + # Nothing left to do... + echo "" + echo "Ok, all done then." + exit 1 + fi + fi } # Script parameter, what do you want to do? @@ -142,56 +155,50 @@ TMP_LOCAL_BUILT_VERSION="/tmp/jetpack2" # Make sure we don't have uncommitted changes. if [[ -n $( git status -s --porcelain ) ]]; then - echo "Uncommitted changes found." - echo "Please deal with them and try again clean." - exit 1 + echo "Uncommitted changes found." + echo "Please deal with them and try again clean." + exit 1 fi # Check the command -if [[ 'new' == $COMMAND || '-n' == $COMMAND ]] -then - create_new_release_branches -elif [[ 'update' = $COMMAND || '-u' = $COMMAND ]] -then - # It's possible they passed the branch name directly to the script - if [[ -z $2 ]] - then - read -p "What branch are you updating? (enter full branch name): " branch - UPDATE_BUILT_BRANCH=$branch - else - UPDATE_BUILT_BRANCH=$2 - fi +if [[ 'new' == $COMMAND || '-n' == $COMMAND ]]; then + create_new_release_branches +elif [[ 'update' = $COMMAND || '-u' = $COMMAND ]]; then + # It's possible they passed the branch name directly to the script + if [[ -z $2 ]]; then + read -p "What branch are you updating? (enter full branch name): " branch + UPDATE_BUILT_BRANCH=$branch + else + UPDATE_BUILT_BRANCH=$2 + fi else - usage + usage fi # Cast the branch name that we'll be building to a single var. -if [[ -n $NEW_BUILT_BRANCH ]] -then - BUILD_TARGET=$NEW_BUILT_BRANCH -elif [[ -n $UPDATE_BUILT_BRANCH ]] -then - BUILD_TARGET=$UPDATE_BUILT_BRANCH +if [[ -n $NEW_BUILT_BRANCH ]]; then + BUILD_TARGET=$NEW_BUILT_BRANCH +elif [[ -n $UPDATE_BUILT_BRANCH ]]; then + BUILD_TARGET=$UPDATE_BUILT_BRANCH else - echo "" - echo "No target branch specified. How did you make it this far?" - exit 1 + echo "" + echo "No target branch specified. How did you make it this far?" + exit 1 fi ### This bit is the engine that will build a branch and push to another one #### # Make sure we're trying to deploy something that exists. if [[ -z $( git branch -r | grep "$BUILD_TARGET" ) ]]; then - echo "Branch $BUILD_TARGET not found in git repository." - echo "" - exit 1 + echo "Branch $BUILD_TARGET not found in git repository." + echo "" + exit 1 fi read -p "You are about to deploy a new production build to the $BUILD_TARGET branch from the $CURRENT_BRANCH branch. Are you sure? [y/N]" -n 1 -r -if [[ $REPLY != "y" && $REPLY != "Y" ]] -then - exit 1 +if [[ $REPLY != "y" && $REPLY != "Y" ]]; then + exit 1 fi echo "" @@ -199,9 +206,9 @@ echo "Building Jetpack" # Checking for yarn hash yarn 2>/dev/null || { - echo >&2 "This script requires you to have yarn package manager installed." - echo >&2 "Please install it following the instructions on https://yarnpkg.com. Aborting."; - exit 1; + echo >&2 "This script requires you to have yarn package manager installed." + echo >&2 "Please install it following the instructions on https://yarnpkg.com. Aborting."; + exit 1; } # Start clean by removing previously installed dependencies and built files @@ -227,8 +234,7 @@ echo "Purging paths included in .svnignore" # check .svnignore for file in $( cat "$DIR/.svnignore" 2>/dev/null ); do # We want to commit changes to to-test.md as well as the testing tips. - if [[ $file == "to-test.md" || $file == "docs/testing/testing-tips.md" ]] - then + if [[ $file == "to-test.md" || $file == "docs/testing/testing-tips.md" ]]; then continue; fi rm -rf TMP_LOCAL_BUILT_VERSION/$file