diff --git a/lib/class-wp-rest-image-editor-controller.php b/lib/class-wp-rest-image-editor-controller.php new file mode 100644 index 00000000000000..fbe61aae8f96d3 --- /dev/null +++ b/lib/class-wp-rest-image-editor-controller.php @@ -0,0 +1,184 @@ +namespace = '__experimental'; + $this->rest_base = '/richimage/(?P[\d]+)'; + $this->editor = new Image_Editor(); + } + + /** + * Registers the necessary REST API routes. + * + * @since 7.x ? + * @access public + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base . '/rotate', + array( + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'rotate_image' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'angle' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/flip', + array( + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'flip_image' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'direction' => array( + 'type' => 'enum', + 'enum' => array( 'vertical', 'horizontal' ), + 'required' => true, + ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/crop', + array( + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'crop_image' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'cropX' => array( + 'type' => 'float', + 'minimum' => 0, + 'required' => true, + ), + 'cropY' => array( + 'type' => 'float', + 'minimum' => 0, + 'required' => true, + ), + 'cropWidth' => array( + 'type' => 'float', + 'minimum' => 1, + 'required' => true, + ), + 'cropHeight' => array( + 'type' => 'float', + 'minimum' => 1, + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Checks if the user has permissions to make the request. + * + * @since 7.x ? + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function permission_callback( $request ) { + $params = $request->get_params(); + + if ( ! current_user_can( 'edit_post', $params['mediaID'] ) ) { + return new WP_Error( 'rest_cannot_edit_image', __( 'Sorry, you are not allowed to edit images.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Rotates an image. + * + * @since 7.x ? + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return array|WP_Error If successful image JSON for the modified image, otherwise a WP_Error. + */ + public function rotate_image( $request ) { + $params = $request->get_params(); + + $modifier = new Image_Editor_Rotate( $params['angle'] ); + + return $this->editor->modify_image( $params['mediaID'], $modifier ); + } + + /** + * Flips/mirrors an image. + * + * @since 7.x ? + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return array|WP_Error If successful image JSON for the modified image, otherwise a WP_Error. + */ + public function flip_image( $request ) { + $params = $request->get_params(); + + $modifier = new Image_Editor_Flip( $params['direction'] ); + + return $this->editor->modify_image( $params['mediaID'], $modifier ); + } + + /** + * Crops an image. + * + * @since 7.x ? + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return array|WP_Error If successful image JSON for the modified image, otherwise a WP_Error. + */ + public function crop_image( $request ) { + $params = $request->get_params(); + + $modifier = new Image_Editor_Crop( $params['cropX'], $params['cropY'], $params['cropWidth'], $params['cropHeight'] ); + + return $this->editor->modify_image( $params['mediaID'], $modifier ); + } +} diff --git a/lib/image-editor/class-image-editor-crop.php b/lib/image-editor/class-image-editor-crop.php new file mode 100644 index 00000000000000..14179e911a2332 --- /dev/null +++ b/lib/image-editor/class-image-editor-crop.php @@ -0,0 +1,126 @@ +crop_x = floatval( $crop_x ); + $this->crop_y = floatval( $crop_y ); + $this->width = floatval( $width ); + $this->height = floatval( $height ); + } + + /** + * Update the image metadata with the modifier. + * + * @access public + * + * @param array $meta Metadata to update. + * @return array Updated metadata. + */ + public function apply_to_meta( $meta ) { + $meta['cropX'] = $this->crop_x; + $meta['cropY'] = $this->crop_y; + $meta['cropWidth'] = $this->width; + $meta['cropHeight'] = $this->height; + + return $meta; + } + + /** + * Apply the modifier to the image + * + * @access public + * + * @param WP_Image_Editor $image Image editor. + * @return bool|WP_Error True on success, WP_Error object or false on failure. + */ + public function apply_to_image( $image ) { + $size = $image->get_size(); + + $crop_x = round( ( $size['width'] * $this->crop_x ) / 100.0 ); + $crop_y = round( ( $size['height'] * $this->crop_y ) / 100.0 ); + $width = round( ( $size['width'] * $this->width ) / 100.0 ); + $height = round( ( $size['height'] * $this->height ) / 100.0 ); + + return $image->crop( $crop_x, $crop_y, $width, $height ); + } + + /** + * Gets the new filename based on metadata. + * + * @access public + * + * @param array $meta Image metadata. + * @return string Filename for the edited image. + */ + public static function get_filename( $meta ) { + if ( isset( $meta['cropWidth'] ) && $meta['cropWidth'] > 0 ) { + $target_file = sprintf( 'crop-%d-%d-%d-%d', round( $meta['cropX'], 2 ), round( $meta['cropY'], 2 ), round( $meta['cropWidth'], 2 ), round( $meta['cropHeight'], 2 ) ); + + // We need to change the original name to include the crop. This way if it's cropped again we won't clash. + $meta['original_name'] = $target_file; + + return $target_file; + } + + return false; + } + + /** + * Gets the default metadata for the crop modifier. + * + * @access public + * + * @return array Default metadata. + */ + public static function get_default_meta() { + return array(); + } +} diff --git a/lib/image-editor/class-image-editor-flip.php b/lib/image-editor/class-image-editor-flip.php new file mode 100644 index 00000000000000..f795913f39c45a --- /dev/null +++ b/lib/image-editor/class-image-editor-flip.php @@ -0,0 +1,127 @@ +direction = 'vertical'; + + if ( 'horizontal' === $direction ) { + $this->direction = $direction; + } + } + + /** + * Update the image metadata with the modifier. + * + * @access public + * + * @param array $meta Metadata to update. + * @return array Updated metadata. + */ + public function apply_to_meta( $meta ) { + if ( $this->is_vertical() ) { + $meta['flipv'] = ! $meta['flipv']; + } elseif ( $this->is_horizontal() ) { + $meta['flipH'] = ! $meta['flipH']; + } + + return $meta; + } + + /** + * Apply the modifier to the image + * + * @access public + * + * @param WP_Image_Editor $image Image editor. + * @return bool|WP_Error True on success, WP_Error object or false on failure. + */ + public function apply_to_image( $image ) { + return $image->flip( $this->is_vertical(), $this->is_horizontal() ); + } + + /** + * Checks if the modifier is a vertical flip + * + * @access private + * + * @return boolean true if the modifier is vertical + */ + private function is_vertical() { + return 'vertical' === $this->direction; + } + + /** + * Checks if the modifier is a horizontal flip + * + * @access private + * + * @return boolean true if the modifier is horizontal + */ + private function is_horizontal() { + return 'horizontal' === $this->direction; + } + + /** + * Gets the new filename based on metadata. + * + * @access public + * + * @param array $meta Image metadata. + * @return string Filename for the edited image. + */ + public static function get_filename( $meta ) { + $parts = array(); + + if ( $meta['flipH'] ) { + $parts[] = 'fliph'; + } + + if ( $meta['flipv'] ) { + $parts[] = 'flipv'; + } + + if ( count( $parts ) > 0 ) { + return implode( '-', $parts ); + } + + return false; + } + + /** + * Gets the default metadata for the flip modifier. + * + * @access public + * + * @return array Default metadata. + */ + public static function get_default_meta() { + return array( + 'flipH' => false, + 'flipv' => false, + ); + } +} diff --git a/lib/image-editor/class-image-editor-rotate.php b/lib/image-editor/class-image-editor-rotate.php new file mode 100644 index 00000000000000..82edd5524e80ca --- /dev/null +++ b/lib/image-editor/class-image-editor-rotate.php @@ -0,0 +1,104 @@ +angle = $this->restrict_angle( intval( $angle, 10 ) ); + } + + /** + * Update the image metadata with the modifier. + * + * @access public + * + * @param array $meta Metadata to update. + * @return array Updated metadata. + */ + public function apply_to_meta( $meta ) { + $meta['rotate'] += $this->angle; + $meta['rotate'] = $this->restrict_angle( $meta['rotate'] ); + + return $meta; + } + + /** + * Apply the rotate modifier to the image + * + * @access public + * + * @param WP_Image_Editor $image Image editor. + * @return bool|WP_Error True on success, WP_Error object or false on failure. + */ + public function apply_to_image( $image ) { + return $image->rotate( 0 - $this->angle ); + } + + /** + * Puts the angle in the range [ 0, 360 ). + * + * @access private + * + * @param integer $angle Angle to restrict. + * @return integer Restricted angle. + */ + private function restrict_angle( $angle ) { + if ( $angle >= 360 ) { + $angle = $angle % 360; + } elseif ( $angle < 0 ) { + $angle = 360 - ( abs( $angle ) % 360 ); + } + + return $angle; + } + + /** + * Gets the new filename based on metadata. + * + * @access public + * + * @param array $meta Image metadata. + * @return string Filename for the edited image. + */ + public static function get_filename( $meta ) { + if ( $meta['rotate'] > 0 ) { + return 'rotate-' . intval( $meta['rotate'], 10 ); + } + + return false; + } + + /** + * Gets the default metadata for the rotate modifier. + * + * @access public + * + * @return array Default metadata. + */ + public static function get_default_meta() { + return array( 'rotate' => 0 ); + } +} diff --git a/lib/image-editor/class-image-editor.php b/lib/image-editor/class-image-editor.php new file mode 100644 index 00000000000000..36f41dfb04a5d0 --- /dev/null +++ b/lib/image-editor/class-image-editor.php @@ -0,0 +1,295 @@ +all_modifiers = array( + 'Image_Editor_Crop', + 'Image_Editor_Flip', + 'Image_Editor_Rotate', + ); + } + + /** + * Modifies an image. + * + * @param integer $media_id Media id. + * @param Image_Editor_Modifier $modifier Modifier to apply to the image. + * @return array|WP_Error If successful image JSON containing the mediaId and url of modified image, otherwise WP_Error. + */ + public function modify_image( $media_id, $modifier ) { + // Get image information. + $info = $this->load_image_info( $media_id ); + if ( is_wp_error( $info ) ) { + return $info; + } + + // Update it with our modifier. + $info['meta'] = $modifier->apply_to_meta( $info['meta'] ); + + // Generate filename based on current attributes. + $target_file = $this->get_filename( $info['meta'] ); + + // Does the image already exist? + $image = $this->get_existing_image( $info, $target_file ); + if ( $image ) { + // Return the existing image. + return $image; + } + + // Try and load the image itself. + $image = $this->load_image( $media_id, $info ); + if ( is_wp_error( $image ) ) { + return $image; + } + + // Finally apply the modification. + $modified = $modifier->apply_to_image( $image['editor'] ); + if ( is_wp_error( $modified ) ) { + return $modified; + } + + // And save. + return $this->save_image( $image, $target_file, $info ); + } + + /** + * Loads an image for editing. + * + * @param integer $media_id Image ID. + * @return array|WP_Error The WP_Image_Editor and image path if successful, WP_Error otherwise. + */ + private function load_image( $media_id ) { + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $image_path = get_attached_file( $media_id ); + + if ( empty( $image_path ) ) { + return new WP_Error( 'fileunknown', 'Unable to find original media file' ); + } + + $image_editor = wp_get_image_editor( $image_path ); + if ( ! $image_editor->load() ) { + return new WP_Error( 'fileload', 'Unable to load original media file' ); + } + + return array( + 'editor' => $image_editor, + 'path' => $image_path, + ); + } + + /** + * Gets the JSON response object for an image. + * + * @param integer $id Image ID. + * @return array Image JSON. + */ + private function get_image_as_json( $id ) { + return array( + 'mediaID' => $id, + 'url' => wp_get_attachment_image_url( $id, 'original' ), + ); + } + + /** + * Checks for the existence of an image and if it exists, return the image. + * + * @param array $attachment Attachment with url to look up. + * @param string $target_file Target file name to look up. + * @return array|false Image JSON if exists, otherwise false. + */ + private function get_existing_image( $attachment, $target_file ) { + $url = str_replace( basename( $attachment['url'] ), $target_file, $attachment['url'] ); + + $new_id = attachment_url_to_postid( $url ); + if ( $new_id > 0 ) { + return $this->get_image_as_json( $new_id ); + } + + return false; + } + + /** + * Saves an edited image. + * + * @param array $image_edit Image path and editor to save. + * @param string $target_name Target file name to save as. + * @param array $attachment Attachment with metadata to apply. + * @return array|WP_Error Image JSON if successful, WP_Error otherwise + */ + private function save_image( $image_edit, $target_name, $attachment ) { + $filename = rtrim( dirname( $image_edit['path'] ), '/' ) . '/' . $target_name; + + // Save to disk. + $saved = $image_edit['editor']->save( $filename ); + + if ( is_wp_error( $saved ) ) { + return $saved; + } + + // Update attachment details. + $attachment_post = array( + 'guid' => $saved['path'], + 'post_mime_type' => $saved['mime-type'], + 'post_title' => pathinfo( $target_name, PATHINFO_FILENAME ), + 'post_content' => '', + 'post_status' => 'inherit', + ); + + // Add this as an attachment. + $attachment_id = wp_insert_attachment( $attachment_post, $saved['path'], 0 ); + if ( 0 === $attachment_id ) { + return new WP_Error( 'attachment', 'Unable to add image as attachment' ); + } + + // Generate thumbnails. + $metadata = wp_generate_attachment_metadata( $attachment_id, $saved['path'] ); + + // Store out meta data. + $metadata[ self::META_KEY ] = $attachment['meta']; + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + return $this->get_image_as_json( $attachment_id ); + } + + /** + * Computes the filename based on metadata. + * + * @param array $meta Metadata for the image. + * @return string Name of the edited file. + */ + private function get_filename( $meta ) { + $parts = array(); + + foreach ( $this->all_modifiers as $modifier ) { + $parts[] = $modifier::get_filename( $meta ); + } + + $parts = array_filter( $parts ); + + if ( count( $parts ) > 0 ) { + return sprintf( '%s-%s', implode( '-', $parts ), $meta['original_name'] ); + } + + return $meta['original_name']; + } + + /** + * Loads image info. + * + * @param integer $media_id Image ID. + * @return array|WP_Error If successful image info, otherwise a WP_Error + */ + private function load_image_info( $media_id ) { + $attachment_info = wp_get_attachment_metadata( $media_id ); + $media_url = wp_get_attachment_image_url( $media_id, 'original' ); + + if ( ! $attachment_info || ! $media_url ) { + return new WP_Error( 'unknown', 'Unable to get meta information for file' ); + } + + $default_meta = array(); + foreach ( $this->all_modifiers as $modifier ) { + $default_meta = array_merge( $default_meta, $modifier::get_default_meta() ); + } + + $info = array( + 'url' => $media_url, + 'media_id' => $media_id, + 'meta' => array_merge( + $default_meta, + array( 'original_name' => basename( $media_url ) ) + ), + ); + + if ( isset( $attachment_info[ self::META_KEY ] ) ) { + $info['meta'] = array_merge( $info['meta'], $attachment_info[ self::META_KEY ] ); + } + + return $info; + } +} + +/** + * Abstract class for image modifiers. Any modifier to an image should implement this. + * + * @abstract + */ +abstract class Image_Editor_Modifier { + + /** + * Update the image metadata with the modifier. + * + * @abstract + * @access public + * + * @param array $meta Metadata to update. + * @return array Updated metadata. + */ + abstract public function apply_to_meta( $meta ); + + /** + * Apply the modifier to the image + * + * @abstract + * @access public + * + * @param WP_Image_Editor $image Image editor. + * @return bool|WP_Error True on success, WP_Error object or false on failure. + */ + abstract public function apply_to_image( $image ); + + /** + * Gets the new filename based on metadata. + * + * @abstract + * @access public + * + * @param array $meta Image metadata. + * @return string Filename for the edited image. + */ + abstract public static function get_filename( $meta ); + + /** + * Gets the default metadata for an image modifier. + * + * @abstract + * @access public + * + * @return array Default metadata. + */ + abstract public static function get_default_meta(); +} diff --git a/lib/load.php b/lib/load.php index 1c849138cbff9a..52899966e73b7e 100644 --- a/lib/load.php +++ b/lib/load.php @@ -54,6 +54,9 @@ function gutenberg_is_experiment_enabled( $name ) { if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) { require_once dirname( __FILE__ ) . '/class-wp-rest-customizer-nonces.php'; } + if ( ! class_exists( 'WP_REST_Image_Editor_Controller' ) ) { + require dirname( __FILE__ ) . '/class-wp-rest-image-editor-controller.php'; + } /** * End: Include for phase 2 */ diff --git a/lib/rest-api.php b/lib/rest-api.php index 9f999958256380..4f5190e82e00a8 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -264,3 +264,14 @@ function gutenberg_auto_draft_get_sample_permalink( $permalink, $id, $title, $na return $permalink; } add_filter( 'get_sample_permalink', 'gutenberg_auto_draft_get_sample_permalink', 10, 5 ); + +/** + * Registers the image editor. + * + * @since 7.x.0 + */ +function gutenberg_register_image_editor() { + $image_editor = new WP_REST_Image_Editor_Controller(); + $image_editor->register_routes(); +} +add_filter( 'rest_api_init', 'gutenberg_register_image_editor' ); diff --git a/package-lock.json b/package-lock.json index de73f17229c637..5830629eb0bdab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9929,6 +9929,7 @@ "@wordpress/editor": "file:packages/editor", "@wordpress/element": "file:packages/element", "@wordpress/escape-html": "file:packages/escape-html", + "@wordpress/hooks": "file:packages/hooks", "@wordpress/i18n": "file:packages/i18n", "@wordpress/icons": "file:packages/icons", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", @@ -9943,6 +9944,7 @@ "lodash": "^4.17.15", "memize": "^1.1.0", "moment": "^2.22.1", + "react-easy-crop": "^3.0.0", "tinycolor2": "^1.4.1" } }, @@ -36157,6 +36159,21 @@ "prop-types": "^15.6.0" } }, + "react-easy-crop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-3.0.0.tgz", + "integrity": "sha512-aP+G2W4WNE4IPRX/YPXna4gsoR+usLilGX/hepzzntZkD+rD1XaziPNOQH6z26NZKdQRDT4Avh37R5cnGmyRHQ==", + "requires": { + "tslib": "1.11.2" + }, + "dependencies": { + "tslib": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.2.tgz", + "integrity": "sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg==" + } + } + }, "react-element-to-jsx-string": { "version": "14.3.1", "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.1.tgz", diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 8db8c70cac898a..8a44bcaa7759b6 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -41,6 +41,7 @@ "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", + "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", @@ -55,6 +56,7 @@ "lodash": "^4.17.15", "memize": "^1.1.0", "moment": "^2.22.1", + "react-easy-crop": "^3.0.0", "tinycolor2": "^1.4.1" }, "publishConfig": { diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 8f232cb0a35e6f..1f3b5effcdab06 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -33,6 +33,7 @@ @import "./post-author/editor.scss"; @import "./pullquote/editor.scss"; @import "./quote/editor.scss"; +@import "./rich-image/editor.scss"; @import "./rss/editor.scss"; @import "./search/editor.scss"; @import "./separator/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index cf54d74dab5120..5a8952134a8273 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -46,6 +46,7 @@ import * as nextpage from './nextpage'; import * as preformatted from './preformatted'; import * as pullquote from './pullquote'; import * as reusableBlock from './block'; +import * as richImage from './rich-image'; import * as rss from './rss'; import * as search from './search'; import * as group from './group'; @@ -215,5 +216,8 @@ export const __experimentalRegisterExperimentalCoreBlocks = ] : [] ), ].forEach( registerBlock ); + + // Attach rich image tools to the image and cover blocks. + richImage.registerBlock(); } : undefined; diff --git a/packages/block-library/src/rich-image/constants.js b/packages/block-library/src/rich-image/constants.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/block-library/src/rich-image/editor.scss b/packages/block-library/src/rich-image/editor.scss new file mode 100644 index 00000000000000..27b6ad63bd6968 --- /dev/null +++ b/packages/block-library/src/rich-image/editor.scss @@ -0,0 +1,87 @@ +// Working State. +.richimage__working { + position: relative; + + .richimage__working-spinner { + position: absolute; + z-index: 1; + left: 50%; + top: calc(50% - #{ $grid-unit-60 }); + transform: translate(-50%, -50%); + } + + img { + opacity: 0.6; + transition: all 0.4s ease; // Make flips smooth. + } + + &.richimage__working__flipv img { + transform: scale(1, -1); + } + + &.richimage__working__fliph img { + transform: scale(-1, 1); + } +} + +// Without this the toolbar buttons gain padding, making them too tall and breaking the toolbar +// This only happens during the processing state as we change the dropdowns into buttons to disable the dropdowns +.richimage-toolbar__working { + padding: 0 6px; +} + +.richimage-crop { + .components-resizable-box__handle { + display: block; + } +} + +.richimage__crop-area { + position: relative; + max-width: 100%; + width: 100%; +} + +.richimage__crop-icon { + padding: 0 8px; + min-width: 48px; + display: flex; + justify-content: center; + align-items: center; + + svg { + fill: currentColor; + } +} + +.richimage__zoom-control { + border: $border-width solid $dark-gray-primary; + border-radius: $radius-block-ui; + background-color: $white; + margin-top: $grid-unit-15; + margin-bottom: $grid-unit-15; + padding: $grid-unit-15 $grid-unit-15 $grid-unit-10; + line-height: 1; +} + +// Make the toolbar dropdowns blend in. +.components-toolbar.richimage-toolbar__dropdown { // Needs specificity. + padding: 0; + border-left: none; + border-right: none; + + .components-icon-button.has-text svg { + margin-right: 0; + } + + .components-dropdown-menu__indicator { + display: none; + } +} + +// Make icons 24x24 unscaled. +.richimage-toolbar__dropdown > .components-button, +.richimage-toolbar__dropdown, +.richimage-toolbar__control { + padding: 6px; +} diff --git a/packages/block-library/src/rich-image/index.js b/packages/block-library/src/rich-image/index.js new file mode 100644 index 00000000000000..f4838b458f3b9e --- /dev/null +++ b/packages/block-library/src/rich-image/index.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ + +import { addFilter } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import RichImage from './rich-image'; + +export const isSupportedBlock = ( blockName ) => + [ 'core/image' ].includes( blockName ); + +const addRichImage = createHigherOrderComponent( ( OriginalBlock ) => { + return ( props ) => { + if ( ! isSupportedBlock( props.name ) ) { + return ; + } + + return ; + }; +}, 'addRichImage' ); + +export function registerBlock() { + addFilter( 'editor.BlockEdit', 'core/rich-image', addRichImage ); +} diff --git a/packages/block-library/src/rich-image/rich-image/api.js b/packages/block-library/src/rich-image/rich-image/api.js new file mode 100644 index 00000000000000..08c282139b455f --- /dev/null +++ b/packages/block-library/src/rich-image/rich-image/api.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ + +import apiFetch from '@wordpress/api-fetch'; + +// Note this always happens with the original media ID. This way the rotation is consistent (otherwise we rotate an already rotated image) +export default function richImageRequest( id, action, attrs ) { + return apiFetch( { + path: `__experimental/richimage/${ id }/${ action }`, + headers: { + 'Content-type': 'application/json', + }, + method: 'POST', + body: JSON.stringify( attrs ), + } ); +} diff --git a/packages/block-library/src/rich-image/rich-image/icon.js b/packages/block-library/src/rich-image/rich-image/icon.js new file mode 100644 index 00000000000000..ef8691ddb78028 --- /dev/null +++ b/packages/block-library/src/rich-image/rich-image/icon.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export const RotateLeftIcon = () => { + return ( + + + + ); +}; + +export const RotateRightIcon = () => { + return ( + + + + ); +}; + +export const FlipHorizontalIcon = () => { + return ( + + + + ); +}; + +export const FlipVerticalIcon = () => { + return ( + + + + ); +}; + +export const CropIcon = () => { + return ( + + + + ); +}; + +export const AspectIcon = () => { + return ( + + + + ); +}; diff --git a/packages/block-library/src/rich-image/rich-image/index.js b/packages/block-library/src/rich-image/rich-image/index.js new file mode 100644 index 00000000000000..477a09b0dedb06 --- /dev/null +++ b/packages/block-library/src/rich-image/rich-image/index.js @@ -0,0 +1,357 @@ +/** + * External dependencies + */ + +import classnames from 'classnames'; +import Cropper from 'react-easy-crop'; + +/** + * WordPress dependencies + */ + +import { + BlockControls, + __experimentalBlock as Block, +} from '@wordpress/block-editor'; +import { Fragment, Component } from '@wordpress/element'; +import { + Toolbar, + ToolbarButton, + Icon, + Button, + Spinner, + withNotices, + RangeControl, + DropdownMenu, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import richImageRequest from './api'; +import { + RotateLeftIcon, + RotateRightIcon, + FlipHorizontalIcon, + FlipVerticalIcon, + CropIcon, + AspectIcon, +} from './icon'; + +const ROTATE_STEP = 90; +const DEFAULT_CROP = { + unit: '%', + x: 25, + y: 25, + width: 50, + height: 50, +}; +const MIN_ZOOM = 1; +const MAX_ZOOM = 3; +const ZOOM_STEP = 0.1; +const POPOVER_PROPS = { position: 'bottom right' }; + +class RichImage extends Component { + constructor( props ) { + super( props ); + + this.state = { + isCrop: false, + inProgress: null, + imageSrc: null, + imageSize: { naturalHeight: 0, naturalWidth: 0 }, + crop: null, + position: { x: 0, y: 0 }, + zoom: 1, + aspect: 4 / 3, + isPortrait: false, + }; + + this.adjustImage = this.adjustImage.bind( this ); + this.cropImage = this.cropImage.bind( this ); + } + + adjustImage( action, attrs ) { + const { setAttributes, attributes, noticeOperations } = this.props; + const { id } = attributes; + + this.setState( { inProgress: action } ); + noticeOperations.removeAllNotices(); + + richImageRequest( id, action, attrs ) + .then( ( response ) => { + this.setState( { inProgress: null, isCrop: false } ); + + if ( response.mediaID && response.mediaID !== id ) { + setAttributes( { + id: response.mediaID, + url: response.url, + } ); + } + } ) + .catch( () => { + noticeOperations.createErrorNotice( + __( + 'Unable to perform the image modification. Please check your media storage.' + ) + ); + this.setState( { inProgress: null, isCrop: false } ); + } ); + } + + cropImage() { + const { crop } = this.state; + + this.adjustImage( 'crop', { + cropX: crop.x, + cropY: crop.y, + cropWidth: crop.width, + cropHeight: crop.height, + } ); + } + + render() { + const { + isSelected, + attributes, + originalBlock: OriginalBlock, + noticeUI, + } = this.props; + const { + isCrop, + inProgress, + position, + zoom, + aspect, + imageSize, + isPortrait, + } = this.state; + const { url } = attributes; + const isEditing = ! isCrop && isSelected && url; + + if ( ! isSelected ) { + return ; + } + + const classes = classnames( { + richimage__working: inProgress !== null, + [ 'richimage__working__' + inProgress ]: inProgress !== null, + } ); + + return ( + + { noticeUI } + +
+ { inProgress && ( +
+ +
+ ) } + + { isCrop ? ( + +
+ { + this.setState( { + position: newPosition, + } ); + } } + onCropComplete={ ( newCrop ) => { + this.setState( { crop: newCrop } ); + } } + onZoomChange={ ( newZoom ) => { + this.setState( { zoom: newZoom } ); + } } + onMediaLoaded={ ( newImageSize ) => { + this.setState( { + imageSize: newImageSize, + } ); + } } + /> +
+ { + this.setState( { zoom: newZoom } ); + } } + /> +
+ ) : ( + + ) } +
+ + { isEditing && ( + + + } + label={ __( 'Rotate' ) } + popoverProps={ POPOVER_PROPS } + controls={ [ + { + icon: , + title: __( 'Rotate left' ), + isDisabled: inProgress, + onClick: () => + this.adjustImage( 'rotate', { + angle: -ROTATE_STEP, + } ), + }, + { + icon: , + title: __( 'Rotate right' ), + isDisabled: inProgress, + onClick: () => + this.adjustImage( 'rotate', { + angle: ROTATE_STEP, + } ), + }, + ] } + /> + } + label={ __( 'Flip' ) } + popoverProps={ POPOVER_PROPS } + controls={ [ + { + icon: , + title: __( 'Flip vertical' ), + isDisabled: inProgress, + onClick: () => + this.adjustImage( 'flip', { + direction: 'vertical', + } ), + }, + { + icon: , + title: __( 'Flip horizontal' ), + isDisabled: inProgress, + onClick: () => + this.adjustImage( 'flip', { + direction: 'horizontal', + } ), + }, + ] } + /> + } + label={ __( 'Crop' ) } + onClick={ () => + this.setState( { + isCrop: ! isCrop, + crop: DEFAULT_CROP, + } ) + } + /> + + + ) } + + { isCrop && ( + + +
+ +
+
+ + } + label={ __( 'Aspect Ratio' ) } + popoverProps={ POPOVER_PROPS } + controls={ [ + { + title: __( '16:10' ), + isDisabled: inProgress, + onClick: () => + this.setState( { + aspect: 16 / 10, + } ), + }, + { + title: __( '16:9' ), + isDisabled: inProgress, + onClick: () => + this.setState( { aspect: 16 / 9 } ), + }, + { + title: __( '4:3' ), + isDisabled: inProgress, + onClick: () => + this.setState( { aspect: 4 / 3 } ), + }, + { + title: __( '3:2' ), + isDisabled: inProgress, + onClick: () => + this.setState( { aspect: 3 / 2 } ), + }, + { + title: __( '1:1' ), + isDisabled: inProgress, + onClick: () => + this.setState( { aspect: 1 } ), + }, + ] } + /> + + this.setState( ( prev ) => ( { + isPortrait: ! prev.isPortrait, + } ) ) + } + /> + + + + + +
+ ) } +
+ ); + } +} + +export default compose( [ withNotices ] )( RichImage );