├── .config └── webpack.config.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── composer.json ├── hm-smart-media.pot ├── inc ├── cropper │ ├── media-template.php │ ├── namespace.php │ └── src │ │ ├── cropper.js │ │ ├── cropper.scss │ │ ├── utils.js │ │ └── views │ │ ├── image-edit-sizes.js │ │ ├── image-edit.js │ │ ├── image-editor.js │ │ └── image-preview.js ├── justified-library │ ├── justified-library.css │ └── namespace.php └── namespace.php ├── package-lock.json ├── package.json └── plugin.php /.config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const ManifestPlugin = require('webpack-manifest-plugin'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | const env = process.env.NODE_ENV || 'production'; 6 | 7 | const commonConfig = { 8 | mode: env, 9 | entry: { 10 | cropper: path.resolve( 'inc/cropper/src/cropper.js' ), 11 | }, 12 | output: { 13 | path: path.resolve( __dirname, '..' ), 14 | filename: 'inc/[name]/build/[name].[hash:8].js', 15 | chunkFilename: 'inc/[name]/build/chunk.[id].[chunkhash:8].js', 16 | publicPath: '/', 17 | libraryTarget: 'this', 18 | jsonpFunction: 'HMSmartMedia' 19 | }, 20 | target: 'web', 21 | resolve: { extensions: [ '.js', '.css', '.scss' ] }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.jsx?$/, 26 | loader: 'babel-loader', 27 | exclude: /(node_modules|bower_components)/, 28 | options: { 29 | babelrc: false, 30 | presets: [ 31 | [ require( '@babel/preset-env' ), { 32 | modules: false, 33 | targets: { browsers: [ ' > 0.01%' ] }, 34 | } ], 35 | ], 36 | plugins: [ 37 | require( '@babel/plugin-proposal-object-rest-spread' ), 38 | require( '@babel/polyfill' ), 39 | ], 40 | }, 41 | }, 42 | { 43 | test: /\.(png|jpg|jpeg|gif|ttf|otf|eot|svg|woff(2)?)(\??[#a-z0-9]+)?$/, 44 | loader: 'url-loader', 45 | options: { 46 | name: '[name].[ext]', 47 | limit: 10000, 48 | fallback: 'file-loader', 49 | publicPath: '/', 50 | }, 51 | }, 52 | { 53 | test: /\.s?css$/, 54 | use: [ 'style-loader', 'css-loader', 'sass-loader' ], 55 | }, 56 | ], 57 | }, 58 | externals: { 59 | 'HM': 'HM', 60 | '@wordpress/backbone': { this: [ 'wp', 'BackBone' ] }, 61 | '@wordpress/media': { this: [ 'wp', 'media' ] }, 62 | '@wordpress/ajax': { this: [ 'wp', 'ajax' ] }, 63 | '@wordpress/template': { this: [ 'wp', 'template' ] }, 64 | '@wordpress/i18n': { this: [ 'wp', 'i18n' ] }, 65 | '@wordpress/hooks': { this: [ 'wp', 'hooks' ] }, 66 | 'jQuery': 'jQuery', 67 | lodash: '_', 68 | wp: 'wp', 69 | }, 70 | optimization: { 71 | noEmitOnErrors: true 72 | }, 73 | performance: { 74 | assetFilter: function assetFilter(assetFilename) { 75 | return !(/\.map$/.test(assetFilename)); 76 | }, 77 | }, 78 | plugins: [ 79 | new ManifestPlugin( { 80 | writeToFileEmit: true, 81 | } ), 82 | new CleanWebpackPlugin( { 83 | cleanOnceBeforeBuildPatterns: ['inc/*/build/**/*'] 84 | } ), 85 | ], 86 | }; 87 | 88 | const devConfig = Object.assign( {}, commonConfig, { 89 | devtool: 'cheap-module-eval-source-map', 90 | devServer: { 91 | port: 9022, 92 | //hot: true, 93 | allowedHosts: [ 94 | '.local', 95 | '.localhost', 96 | '.test', 97 | ], 98 | overlay: { 99 | warnings: true, 100 | errors: true 101 | } 102 | }, 103 | } ); 104 | 105 | //devConfig.plugins.push( new webpack.HotModuleReplacementPlugin() ); 106 | 107 | const productionConfig = Object.assign( {}, commonConfig, {} ); 108 | 109 | const config = env === 'production' ? productionConfig : devConfig; 110 | 111 | module.exports = config; 112 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*' 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | release: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '12' 16 | - name: Get the version 17 | id: get_version 18 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 19 | - name: Create Release 20 | uses: technote-space/release-github-actions@v7 21 | with: 22 | CLEAN_TEST_TAG: true 23 | CLEAN_TARGETS: .[!.]*,__tests__,package.json,yarn.lock,node_modules,tests,*.xml.dist 24 | COMMIT_MESSAGE: "Built release for ${{ steps.get_version.outputs.VERSION }}. For a full change log look at the notes within the original/${{ steps.get_version.outputs.VERSION }} release." 25 | CREATE_MAJOR_VERSION_TAG: false 26 | CREATE_MINOR_VERSION_TAG: false 27 | CREATE_PATCH_VERSION_TAG: false 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | ORIGINAL_TAG_PREFIX: original/ 30 | OUTPUT_BUILD_INFO_FILENAME: build.json 31 | TEST_TAG_PREFIX: test/ 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | manifest.json 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## v0.5.9 5 | 6 | - Bug: Fix wp_img_tag_add_auto_sizes not available before WP 6.7 7 | 8 | ## v0.5.8 9 | 10 | - Bug: Fix adding image optimization attributes to images 11 | 12 | ## v0.5.7 13 | 14 | - Bug: Add image optimization attributes to images 15 | 16 | ## v0.4.3 17 | 18 | - Bug: Check model has mime type before checking value 19 | 20 | ## v0.4.2 21 | 22 | - Bug: Don't trigger edit modal for non image files 23 | 24 | ## v0.4.1 25 | 26 | - Bug: Fix WebP support for cropping and display 27 | 28 | ## v0.4.0 29 | 30 | - Enhancement: Add filter to skip attachments during Smart Media processing 31 | 32 | ## v0.3.11 33 | 34 | - Bug: AMP plugin compatibility with featured image modal selection 35 | 36 | ## v0.3.10 37 | 38 | - Enhancement: Add Asset Manager Framework support 39 | ## v0.3.9 40 | 41 | - Bug: Fall back to regular preview when missing `editor` object on attachment model 42 | 43 | ## v0.3.8 44 | 45 | - Bug: Ensure srcset URLs are properly escaped 46 | 47 | ## v0.3.7 48 | 49 | - Bug: Fix REST API upload handling 50 | - Bug: Fix media templates when missing `.can` properties 51 | 52 | ## v0.3.6 53 | 54 | - Bug: Fix modal state when clicking featured image on first page load 55 | 56 | ## v0.3.5 57 | 58 | - Bug: Cropping UI not showing in WP 5.6 with jQuery v3 59 | 60 | ## v0.3.4 61 | 62 | - Bug: Ensure width and height attributes are correct for uncropped image sizes 63 | 64 | ## v0.3.3 65 | 66 | - Bug: Don't switch to edit state if attachment is a fake placeholder such as the cover block default 67 | 68 | ## v0.3.2 69 | 70 | - Bug: Inline image editing in image block fails with files containing unicode characters 71 | 72 | ## v0.3.1 73 | 74 | - Bug: Check wp.data store before using to support classic editor 75 | 76 | # v0.3.0 77 | 78 | - Enhancement: WP 5.5 Support 79 | - Enhancement: Don't strip width and height attributes 80 | - Enhancement: Switch to edit mode during attachment upload 81 | 82 | ## v0.2.22 83 | 84 | - Bug: Allow for original image URLs with existing query string 85 | 86 | ## v0.2.21 87 | 88 | - Bug: Fix gallery editor styling 89 | 90 | ## v0.2.20 91 | 92 | - Bug: Fix uploader error after inserting existing media into post 93 | 94 | ## v0.2.19 95 | 96 | - Enhancement: Add width and height attributes to images in media library to improve loading and fix layout jitter 97 | 98 | ## v0.2.18 99 | 100 | - Bug: Ensure PDF thumbnails use Tachyon #81 101 | - Bug: Fix REST API URLs to use manual crop dimensions #81 102 | 103 | ## v0.2.17 104 | 105 | - Bug: Ensure cropping UI selectors are scoped to current frame #80 106 | - Bug: Prevent dismissal of crop select area by clicking on crop background #79 107 | 108 | ## v0.2.16 109 | 110 | - Bug: Fixed featured image "Replace Image" button behaviour #76 111 | 112 | ## v0.2.15 113 | 114 | - Bug: Fixed fatal error uploading video and audio blocks. #71 115 | 116 | ## v0.2.14 117 | 118 | - Bug: Deselect blocks on focus inside metabox area, prevents updating blocks on image selection outside editor 119 | 120 | ## v0.2.13 121 | 122 | - Bug: Compatibility issue with Google AMP plugin 123 | - Bug: Selecting a featured image on an auto draft / new post page could fail to trigger the select event 124 | 125 | ## v0.2.12 126 | 127 | - Bug: Fix SVG preview display in media modal 128 | 129 | ## v0.2.11 130 | 131 | - Bug: Tachyon no longer processes SVGs, they stopped displaying properly in the media modal as a result 132 | 133 | ## v0.2.10 134 | 135 | - Bug: Ensure `srcset` sizes are never larger than the original 136 | - Bug: Ensure originally requested size (or as close to) is represented in `srcset` 137 | 138 | ## v0.2.9 139 | 140 | - Bug: Fix crop position in post featured images #52 141 | - Bug: Fix cropper showing on Featured Media Gutenberg popup #51 142 | 143 | ## v0.2.8 144 | 145 | - Bug: Fix image size selector in Image Block wasn't selecting the correct size and switching between sizes wasn't causing any resize in the block. 146 | 147 | ## v0.2.7 148 | 149 | - Bug: Fix error when `media_details` is empty in REST response 150 | 151 | ## v0.2.6 152 | 153 | - Bug: Fatal error on requesting PDF via REST API 154 | - Bug: PDF thumbnails not using Tachyon 155 | - Bug: Media modal showing incorrect URL for PDF preview 156 | 157 | ## v0.2.5 158 | 159 | - Change: Remove default WP large image sizes 1536x1536 and 2048x2048 in favour of Tachyon zoom srcset 160 | - Bug: An image cannot be selected until Change Image is clicked #36 161 | - Bug: Image cropper not applying size selection to image blocks #37 162 | - Bug: Ensure attachment ID in `image_downsize` filter is int 163 | 164 | ## v0.2.4 165 | 166 | - Bug: Check metadata is an array before passing to `add_srcset_and_sizes()` 167 | 168 | ## v0.2.3 169 | 170 | - Bug: Don't hide edit image link if smart media UI not active #30 171 | 172 | ## v0.2.2 173 | 174 | - Bug: Add missing textdomains 175 | - Bug: Register textdomain for script handle 176 | - Bug: Add wp-i18n as a dependency of the cropper 177 | 178 | ## v0.2.1 179 | 180 | - Bug: Handle max size when non-named crop size is requested #22 181 | 182 | ## v0.2.0 183 | 184 | - Enhancement: Allow removal of custom crops 185 | - Enhancement: Ensure tachyon URLs are used globally 186 | - Enhancement: Add crop and focal point data to REST API responses 187 | - Enhancement: Support srcset and sizes for stored tachyon URLs 188 | 189 | ## v0.1.15 190 | 191 | - Bug: Allow cropper to update crops for sizes that contain special characters #17 192 | 193 | ## v0.1.14 194 | 195 | - Bug: Fix SVG compatibility #15 196 | 197 | ## v0.1.13 198 | 199 | - Bug: Fix warnings when trying to get crop data for special case image sizes #5 200 | 201 | ## v0.1.12 202 | 203 | - Update: Upgraded all build scripts 204 | - Update: Add support for images in posts added by the block editor #9 205 | - Update: Add contributing docs 206 | 207 | ## v0.1.11 208 | 209 | - Bug: Justified library CSS - increase specifity 210 | - Bug: Load media templates on customiser 211 | - Bug: Don't load edit mode when built in media modal cropper state is present 212 | 213 | ## v0.1.10 214 | 215 | - Justified library CSS - Add media-views stylesheet as a dependency. 216 | - Justified library CSS - Enqueued on the `wp_enqueue_media` action to ensure they are only loaded when required. 217 | 218 | ## v0.1.9 219 | 220 | - Fix bug when `full` isn't in sizes list, eg. everywhere except the HM site. 221 | 222 | ## v0.1.8 223 | 224 | - Added composer.json 225 | 226 | ## v0.1.7 227 | 228 | - Bug fix, compat issues with CMB2 229 | - Disable thumbnail file generation when tachyon is enabled 230 | 231 | ## v0.1.6 232 | 233 | - Bug fixes for focal point generated thumbs, bypass smart crop entirely 234 | 235 | ## v0.1.5 236 | 237 | - Added focal point cropping feature 238 | 239 | ## v0.1.4 240 | 241 | - When editing an image in the post edit screen the editing experience is loaded with an option to change the image 242 | - Split out the image editor views JS 243 | - Fix justified library in non script debug mode 244 | 245 | ## v0.1.3 246 | 247 | - Minor bug fixes 248 | - Styling updates 249 | 250 | ## v0.1.2 251 | 252 | - Ensure only image attachment JS is modified 253 | - Use smaller thumbs in size picker where possible 254 | - Fix re-render on navigation between images in Media Library (frame refresh event) 255 | 256 | ## v0.1.1 257 | 258 | - Fix bug loading image editor when `SCRIPT_DEBUG` is false 259 | 260 | ## v0.1.0 261 | 262 | - Initial release 263 | - Justified media library 264 | - New image editing experience (Media section of admin only so far) 265 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To get started follow these steps: 4 | 5 | 1. `git clone git@github.com:humanmade/smart-media.git` or fork the repository and clone your fork. 6 | 1. `cd smart-media` 7 | 1. `npm install` 8 | 1. `npm run start` for the development server 9 | 1. `npm run build` to build the assets 10 | 11 | You should then start working on your fork or branch. 12 | 13 | ## Making a pull request 14 | 15 | When you make a pull request it will be reviewed. You should also update the `CHANGELOG.md` - add lines after the title such as `- Bug: Fixed a typo #33` to describe each change and link to the issues if applicable. 16 | 17 | If the change should be applied to previous versions, such as a bugfix, add label or request that labels such as `backport v0-4-branch` are added to the pull request. New pull requests to those branches will be created automatically. 18 | 19 | ## Making a new release 20 | 21 | 22 | 1. Checkout the target release branch such as `v0-4-branch` 23 | - If the target release branch is the latest stable then use the `master` branch, and backport the changes 24 | 2. Update the version number in `plugin.php` to reflect the nature of the changes, this plugin follows semver versioning. 25 | - For small backwards comaptible changes like bug fixes update the patch version 26 | - For changes that add functionality without changing existing functionality update the minor version 27 | - For breaking or highly significant changes update the major version 28 | 3. Add a title heading for the version number above the latest updates in `CHANGELOG.md` 29 | 4. Commit and push the changes 30 | - If making the changes against `master` create a pull request instead and backport it to the latest stable branch 31 | 5. Go to the releases tab and create a new release from the target release branch, set the new tag name to match the updated version number 32 | 33 | You may need to repeat this process when backporting fixes to multiple release branches. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 9 | 10 | 11 | 14 | 17 | 18 |
4 | Smart Media
5 | Enhanced media library features for WordPress. 6 |
8 |
12 | A Human Made project. Maintained by @roborourke. 13 | 15 | 16 |
19 | 20 | Smarter media features for WordPress. 21 | 22 | Some features in this plugin will work on their own however some are designed to augment the existing tools we use such as [Tachyon](https://github.com/humanmade/tachyon). 23 | 24 | ## Features 25 | 26 | ### Justified media library 27 | 28 | The media library shows square thumbnails by default which can make it harder to find the right image. This feature makes the thumbnails keep their original aspect ratio, similar to the UI of Flickr. 29 | 30 | To disable the feature add the following: 31 | 32 | ```php 33 | ` 51 | - `smartmedia.cropper.selectSizeFromBlockAttributes.` 52 | 53 | In the above filters `` should be replaced a dot separated version of your block name, for example `core/image` becomes `core.image`. The core image block attributes are mapped by default. 54 | 55 | Mapping the selected image to block attributes: 56 | 57 | ```js 58 | addFilter( 59 | 'smartmedia.cropper.updateBlockAttributesOnSelect.core.image', 60 | 'smartmedia/cropper/update-block-on-select/core/image', 61 | /** 62 | * @param {?Object} attributes The filtered block attributes. Return null to bypass updating. 63 | * @param {Object} image The image data has the following shape: 64 | * { 65 | * name: , The image size name 66 | * url: , The URL for the sized image 67 | * width: , The width in pixels 68 | * height: , The height in pixels 69 | * label: , The human readable name for the image size, only present for user selectable sizes 70 | * cropData: , Null or object containing x, y, width and height properties 71 | * } 72 | */ 73 | ( attributes, image ) => { 74 | // Only user selectable image sizes have a label so return early if this is missing. 75 | if ( ! image.label ) { 76 | return attributes; 77 | } 78 | 79 | return { 80 | sizeSlug: image.size, 81 | url: image.url, 82 | }; 83 | } 84 | ); 85 | ``` 86 | 87 | Update the cropping UI selected size based on selected block attributes: 88 | 89 | ```js 90 | addFilter( 91 | 'smartmedia.cropper.selectSizeFromBlockAttributes.core.image', 92 | 'smartmedia/cropper/select-size-from-block-attributes/core/image', 93 | /** 94 | * @param {?String} size The image size slug. 95 | * @param {Object} block The currently selected block. 96 | */ 97 | ( size, block ) => { 98 | return size || block.attributes.sizeSlug || 'full'; 99 | } 100 | ); 101 | ``` 102 | 103 | The function takes 2 parameters: 104 | 105 | - `block`: The name of the block to map attributes for 106 | - `callback`: A function that accepts the image `size` name, an `image` object containing `url`, `width`, `height`, crop data and label for the image size, and lastly the full `attachment` data object. 107 | 108 | The callback should return an object or `null`. Passing `null` will prevent updating the currently selected block. 109 | 110 | ## Roadmap 111 | 112 | Planned features include: 113 | 114 | - Duplicate image detection and consolidation 115 | - EXIF data editor 116 | 117 | ## Contributing 118 | 119 | First of all thanks for using this plugin and thanks for contributing! 120 | 121 | To get started check out the [contributing documentation](./CONTRIBUTING.md). 122 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/smart-media", 3 | "description": "Smart Media features for HM Cloud", 4 | "type": "wordpress-plugin", 5 | "license": "GPL-3.0", 6 | "authors": [ 7 | { 8 | "name": "Robert O'Rourke", 9 | "email": "rob@humanmade.com" 10 | } 11 | ], 12 | "require": {} 13 | } 14 | -------------------------------------------------------------------------------- /hm-smart-media.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Human Made Limited 2 | # This file is distributed under the same license as the Smart Media plugin. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Smart Media 0.2.2\n" 6 | "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/smart-media\n" 7 | "Last-Translator: FULL NAME \n" 8 | "Language-Team: LANGUAGE \n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "POT-Creation-Date: 2019-10-30T18:17:57+00:00\n" 13 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 | "X-Generator: WP-CLI 2.4.0-alpha\n" 15 | "X-Domain: hm-smart-media\n" 16 | 17 | #. Plugin Name of the plugin 18 | msgid "Smart Media" 19 | msgstr "" 20 | 21 | #. Description of the plugin 22 | msgid "Advanced media tools that take advantage of Rekognition and Tachyon." 23 | msgstr "" 24 | 25 | #. Author of the plugin 26 | msgid "Human Made Limited" 27 | msgstr "" 28 | 29 | #: inc/cropper/media-template.php:159 30 | msgid "Image sizes" 31 | msgstr "" 32 | 33 | #: inc/cropper/media-template.php:194 34 | msgid "Edit original image" 35 | msgstr "" 36 | 37 | #: inc/cropper/media-template.php:196 38 | msgid "Edit crop" 39 | msgstr "" 40 | 41 | #: inc/cropper/media-template.php:224 42 | msgid "Click anywhere on the image to set a focal point for automatic cropping." 43 | msgstr "" 44 | 45 | #: inc/cropper/media-template.php:232 46 | msgid "Apply changes" 47 | msgstr "" 48 | 49 | #: inc/cropper/media-template.php:233 50 | msgid "Reset" 51 | msgstr "" 52 | 53 | #: inc/cropper/media-template.php:235 54 | msgid "The crop was set automatically, to override it click and drag on the image then use the crop button." 55 | msgstr "" 56 | 57 | #: inc/cropper/media-template.php:237 58 | msgid "Remove custom crop" 59 | msgstr "" 60 | 61 | #: inc/cropper/media-template.php:254 62 | msgid "Click to remove focal point" 63 | msgstr "" 64 | 65 | #: inc/cropper/namespace.php:322 66 | msgid "Invalid thumbnail size received" 67 | msgstr "" 68 | 69 | #: inc/cropper/namespace.php:329 70 | msgid "No cropping data received" 71 | msgstr "" 72 | 73 | #: inc/cropper/namespace.php:353 74 | msgid "No focal point data received" 75 | msgstr "" 76 | 77 | #. translators: %s is replaced by the text 'id' referring to the parameter name. 78 | #: inc/cropper/namespace.php:387 79 | msgid "Invalid %s parameter." 80 | msgstr "" 81 | 82 | #: inc/cropper/namespace.php:391 83 | msgid "That is not a valid image attachment." 84 | msgstr "" 85 | 86 | #: inc/cropper/namespace.php:395 87 | msgid "You are not allowed to edit this attachment." 88 | msgstr "" 89 | 90 | #: inc/cropper/src/cropper.js:89 91 | msgid "Edit image" 92 | msgstr "" 93 | 94 | #: inc/cropper/src/cropper.js:133 95 | msgid "Change image" 96 | msgstr "" 97 | 98 | #: inc/cropper/src/cropper.js:142 99 | msgid "Select" 100 | msgstr "" 101 | -------------------------------------------------------------------------------- /inc/cropper/media-template.php: -------------------------------------------------------------------------------- 1 | 159 | 160 | 163 | 164 | 197 | 198 | 274 | 275 | 293 | -------------------------------------------------------------------------------- /inc/cropper/namespace.php: -------------------------------------------------------------------------------- 1 | get_data(); 149 | 150 | if ( is_wp_error( $data ) ) { 151 | return $response; 152 | } 153 | 154 | if ( is_object( $data ) ) { 155 | $data = (array) $data; 156 | } 157 | 158 | // Confirm it's definitely an image. 159 | if ( ! isset( $data['id'] ) || ! isset( $data['media_type'] ) || (strpos( $data['media_type'], 'image/' ) !== 0 ) ) { 160 | return $response; 161 | } 162 | 163 | // Check if we should skip this one. 164 | if ( skip_attachment( $data['id'] ) ) { 165 | return $response; 166 | } 167 | 168 | if ( isset( $data['source_url'] ) && $data['media_type'] === 'image' ) { 169 | $data['original_url'] = $data['source_url']; 170 | if ( function_exists( 'tachyon_url' ) ) { 171 | $data['source_url'] = tachyon_url( $data['source_url'] ); 172 | } 173 | 174 | // Add focal point. 175 | $focal_point = get_post_meta( $data['id'], '_focal_point', true ); 176 | if ( empty( $focal_point ) ) { 177 | $data['focal_point'] = null; 178 | } else { 179 | $data['focal_point'] = (object) array_map( 'absint', $focal_point ); 180 | } 181 | } 182 | 183 | // Clean full size URL and ensure file thumbs use Tachyon URLs. 184 | if ( isset( $data['media_details'] ) && is_array( $data['media_details'] ) && isset( $data['media_details']['sizes'] ) ) { 185 | // Handle REST response sizes format. 186 | if ( is_object( $data['media_details']['sizes'] ) ) { 187 | $data['media_details']['sizes'] = (array) $data['media_details']['sizes']; 188 | } 189 | $full_size_thumb = $data['original_url'] ?? $data['media_details']['sizes']['full']['source_url']; 190 | foreach ( $data['media_details']['sizes'] as $name => $size ) { 191 | // Remove internal flag. 192 | unset( $size['_tachyon_dynamic'] ); 193 | 194 | // Handle PDF / file thumbs. 195 | if ( $data['media_type'] !== 'image' && function_exists( 'tachyon_url' ) ) { 196 | if ( $name === 'full' ) { 197 | $size['source_url'] = tachyon_url( $full_size_thumb ); 198 | } else { 199 | $size['source_url'] = tachyon_url( $full_size_thumb, [ 200 | 'resize' => sprintf( '%d,%d', $size['width'], $size['height'] ), 201 | ] ); 202 | } 203 | } 204 | 205 | // Handle image sizes. 206 | if ( $data['media_type'] === 'image' ) { 207 | // Correct full size image details. 208 | if ( $name === 'full' ) { 209 | $size['file'] = explode( '?', $size['file'] )[0]; 210 | $size['source_url'] = $data['source_url']; 211 | } 212 | } 213 | 214 | $data['media_details']['sizes'][ $name ] = $size; 215 | } 216 | } 217 | 218 | $response->set_data( $data ); 219 | return $response; 220 | } 221 | 222 | /** 223 | * Add crop data to image_downsize() tachyon args. 224 | * 225 | * @param array $tachyon_args 226 | * @param array $downsize_args 227 | * @return array 228 | */ 229 | function image_downsize( array $tachyon_args, array $downsize_args ) : array { 230 | if ( ! isset( $downsize_args['attachment_id'] ) || ! isset( $downsize_args['size'] ) ) { 231 | return $tachyon_args; 232 | } 233 | 234 | // The value we're picking up can be filtered and upstream bugs introduced, this will avoid fatal errors. 235 | // We have to check if value is "numeric" (int or string with a number) as in < WordPress 5.3 236 | // get_post_thumbnail_id() returns a string. 237 | if ( ! is_numeric( $downsize_args['attachment_id'] ) ) { 238 | return $tachyon_args; 239 | } 240 | 241 | $attachment_id = (int) $downsize_args['attachment_id']; 242 | $crop = get_crop( $attachment_id, $downsize_args['size'] ); 243 | 244 | if ( $crop ) { 245 | // Remove crop strategy param if present. 246 | unset( $tachyon_args['crop_strategy'] ); 247 | $tachyon_args['crop'] = sprintf( '%dpx,%dpx,%dpx,%dpx', $crop['x'], $crop['y'], $crop['width'], $crop['height'] ); 248 | } 249 | 250 | return $tachyon_args; 251 | } 252 | 253 | /** 254 | * Get crop data for a given image and size. 255 | * 256 | * @param int $attachment_id 257 | * @param string $size 258 | * @return array 259 | */ 260 | function get_crop( int $attachment_id, string $size ) : ?array { 261 | // Fetch all registered image sizes. 262 | $sizes = get_image_sizes(); 263 | 264 | // Check it's that passed in size exists. 265 | if ( ! isset( $sizes[ $size ] ) ) { 266 | return null; 267 | } 268 | 269 | $crop = get_post_meta( $attachment_id, "_crop_{$size}", true ) ?: []; 270 | 271 | // Infer crop from focal point if available. 272 | if ( empty( $crop ) ) { 273 | $meta_data = wp_get_attachment_metadata( $attachment_id ); 274 | if ( ! $meta_data ) { 275 | return null; 276 | } 277 | 278 | $size = $sizes[ $size ]; 279 | 280 | $focal_point = get_post_meta( $attachment_id, '_focal_point', true ) ?: []; 281 | $focal_point = array_map( 'absint', $focal_point ); 282 | 283 | if ( ! empty( $focal_point ) && $size['crop'] ) { 284 | // Get max size of crop aspect ratio within original image. 285 | $dimensions = get_maximum_crop( $meta_data['width'], $meta_data['height'], $size['width'], $size['height'] ); 286 | 287 | if ( $dimensions[0] === $meta_data['width'] && $dimensions[1] === $meta_data['height'] ) { 288 | return null; 289 | } 290 | 291 | $crop['width'] = $dimensions[0]; 292 | $crop['height'] = $dimensions[1]; 293 | 294 | // Set x & y but constrain within original image bounds. 295 | $crop['x'] = min( $meta_data['width'] - $crop['width'], max( 0, $focal_point['x'] - ( $crop['width'] / 2 ) ) ); 296 | $crop['y'] = min( $meta_data['height'] - $crop['height'], max( 0, $focal_point['y'] - ( $crop['height'] / 2 ) ) ); 297 | } 298 | } 299 | 300 | if ( empty( $crop ) ) { 301 | return null; 302 | } 303 | 304 | return $crop; 305 | } 306 | 307 | /** 308 | * Get the maximum size of a target crop within the original image width & height. 309 | * 310 | * @param integer $width 311 | * @param integer $height 312 | * @param integer $crop_width 313 | * @param integer $crop_height 314 | * @return array 315 | */ 316 | function get_maximum_crop( int $width, int $height, int $crop_width, int $crop_height ) { 317 | $max_height = $width / $crop_width * $crop_height; 318 | 319 | if ( $max_height < $height ) { 320 | return [ $width, round( $max_height ) ]; 321 | } 322 | 323 | return [ round( $height / $crop_height * $crop_width ), $height ]; 324 | } 325 | 326 | /** 327 | * Add extra meta data to attachment js. 328 | * 329 | * @param array $response 330 | * @param WP_Post $attachment 331 | * @return array 332 | */ 333 | function attachment_js( $response, $attachment ) { 334 | if ( ! wp_attachment_is_image( $attachment ) ) { 335 | return $response; 336 | } 337 | 338 | // We can't edit SVGs. 339 | if ( $response['mime'] === 'image/svg+xml' ) { 340 | return $response; 341 | } 342 | 343 | // Check if we should skip. 344 | if ( skip_attachment( $attachment->ID ) ) { 345 | return $response; 346 | } 347 | 348 | $meta = wp_get_attachment_metadata( $attachment->ID ); 349 | 350 | if ( ! $meta ) { 351 | return $response; 352 | } 353 | 354 | $backup_sizes = get_post_meta( $attachment->ID, '_wp_attachment_backup_sizes', true ); 355 | 356 | // Check width and height are set, in rare cases it can fail. 357 | if ( ! isset( $meta['width'] ) || ! isset( $meta['height'] ) ) { 358 | trigger_error( sprintf( 'Image metadata generation failed for image ID "%d", this may require manual resolution.', $attachment->ID ), E_USER_WARNING ); 359 | return $response; 360 | } 361 | 362 | $big = max( $meta['width'], $meta['height'] ); 363 | $sizer = $big > 400 ? 400 / $big : 1; 364 | 365 | // Add capabilities and permissions for imageEdit. 366 | $response['editor'] = [ 367 | 'nonce' => wp_create_nonce( "image_editor-{$attachment->ID}" ), 368 | 'sizer' => $sizer, 369 | 'can' => [ 370 | 'rotate' => wp_image_editor_supports( [ 371 | 'mime_type' => get_post_mime_type( $attachment->ID ), 372 | 'methods' => [ 'rotate' ], 373 | ] ), 374 | 'restore' => false, 375 | ], 376 | ]; 377 | 378 | if ( ! empty( $backup_sizes ) && isset( $backup_sizes['full-orig'], $meta['file'] ) ) { 379 | $response['editor']['can']['restore'] = $backup_sizes['full-orig']['file'] !== basename( $meta['file'] ); 380 | } 381 | 382 | // Add base Tachyon URL. 383 | if ( method_exists( 'Tachyon', 'validate_image_url' ) && Tachyon::validate_image_url( $response['url'] ) ) { 384 | $response['original_url'] = $response['url']; 385 | $response['url'] = tachyon_url( $response['url'] ); 386 | } 387 | 388 | // Fill intermediate sizes array. 389 | $sizes = get_image_sizes(); 390 | 391 | if ( isset( $response['sizes']['full'] ) ) { 392 | $full_size_attrs = $response['sizes']['full']; 393 | 394 | // Fill the full size manually as the Media Library needs this size. 395 | $sizes['full'] = [ 396 | 'width' => $full_size_attrs['width'], 397 | 'height' => $full_size_attrs['height'], 398 | 'crop' => false, 399 | 'orientation' => $full_size_attrs['width'] >= $full_size_attrs['height'] ? 'landscape' : 'portrait', 400 | ]; 401 | } 402 | 403 | $size_labels = apply_filters( 'image_size_names_choose', [ 404 | 'thumbnail' => __( 'Thumbnail' ), 405 | 'medium' => __( 'Medium' ), 406 | 'large' => __( 'Large' ), 407 | 'full' => __( 'Full Size' ), 408 | ] ); 409 | 410 | $response['sizes'] = array_map( function ( $size, $name ) use ( $attachment, $size_labels ) { 411 | $src = wp_get_attachment_image_src( $attachment->ID, $name ); 412 | 413 | $size['name'] = $name; 414 | $size['label'] = $size_labels[ $name ] ?? null; 415 | $size['url'] = $src[0]; 416 | $size['width'] = $src[1]; 417 | $size['height'] = $src[2]; 418 | $size['cropData'] = (object) ( get_post_meta( $attachment->ID, "_crop_{$name}", true ) ?: [] ); 419 | 420 | return $size; 421 | }, $sizes, array_keys( $sizes ) ); 422 | 423 | $response['sizes'] = array_combine( array_keys( $sizes ), $response['sizes'] ); 424 | 425 | // Focal point. 426 | $response['focalPoint'] = (object) ( get_post_meta( $attachment->ID, '_focal_point', true ) ?: [] ); 427 | 428 | return $response; 429 | } 430 | 431 | /** 432 | * Check whether to skip an attachment for Smart Media processing. 433 | * 434 | * @uses filter hm.smart-media.skip-attachment 435 | * 436 | * @param integer $attachment_id The attachment ID to check. 437 | * @return boolean 438 | */ 439 | function skip_attachment( int $attachment_id ) : bool { 440 | /** 441 | * Filters whether to skip a given attachment. 442 | * 443 | * @param bool $skip If true then the attachment should be skipped, default false. 444 | * @param int $attachment_id The attachment ID to check. 445 | */ 446 | return (bool) apply_filters( 'hm.smart-media.skip-attachment', false, $attachment_id ); 447 | } 448 | 449 | /** 450 | * Updates attachments that aren't images but have thumbnails 451 | * like PDFs to use Tachyon URLs. 452 | * 453 | * @param array $response The attachment JS. 454 | * @param WP_Post $attachment The attachment post object. 455 | * @return array 456 | */ 457 | function attachment_thumbs( $response, $attachment ) : array { 458 | if ( ! function_exists( 'tachyon_url' ) ) { 459 | return $response; 460 | } 461 | 462 | if ( ! is_array( $response ) || wp_attachment_is_image( $attachment ) ) { 463 | return $response; 464 | } 465 | 466 | if ( skip_attachment( $attachment->ID ) ) { 467 | return $response; 468 | } 469 | 470 | // Handle attachment thumbnails. 471 | $full_size_thumb = $response['sizes']['full']['url'] ?? false; 472 | 473 | if ( ! $full_size_thumb || ! isset( $response['sizes'] ) ) { 474 | return $response; 475 | } 476 | 477 | foreach ( $response['sizes'] as $name => $size ) { 478 | if ( $name === 'full' ) { 479 | $response['sizes'][ $name ]['url'] = tachyon_url( $full_size_thumb ); 480 | } else { 481 | $response['sizes'][ $name ]['url'] = tachyon_url( $full_size_thumb, [ 482 | 'resize' => sprintf( '%d,%d', $size['width'], $size['height'] ), 483 | ] ); 484 | } 485 | } 486 | 487 | return $response; 488 | } 489 | 490 | /** 491 | * AJAX handler for saving the cropping coordinates of a thumbnail size for a given attachment. 492 | */ 493 | function ajax_save_crop() { 494 | // Get the attachment. 495 | $attachment = validate_parameters(); 496 | 497 | check_ajax_referer( 'image_editor-' . $attachment->ID ); 498 | 499 | $name = sanitize_text_field( wp_unslash( $_POST['size'] ) ); 500 | 501 | if ( ! in_array( $name, array_keys( get_image_sizes() ), true ) ) { 502 | wp_send_json_error( __( 'Invalid thumbnail size received', 'hm-smart-media' ) ); 503 | } 504 | 505 | $action = sanitize_key( $_POST['action'] ); 506 | 507 | if ( $action === 'hm_save_crop' ) { 508 | if ( ! isset( $_POST['crop'] ) ) { 509 | wp_send_json_error( __( 'No cropping data received', 'hm-smart-media' ) ); 510 | } 511 | 512 | $crop = map_deep( wp_unslash( $_POST['crop'] ), 'absint' ); 513 | update_post_meta( $attachment->ID, "_crop_{$name}", $crop ); 514 | } 515 | 516 | if ( $action === 'hm_remove_crop' ) { 517 | delete_post_meta( $attachment->ID, "_crop_{$name}" ); 518 | } 519 | 520 | wp_send_json_success(); 521 | } 522 | 523 | /** 524 | * AJAX handler for saving the cropping coordinates of a thumbnail size for a given attachment. 525 | */ 526 | function ajax_save_focal_point() { 527 | // Get the attachment. 528 | $attachment = validate_parameters(); 529 | 530 | check_ajax_referer( 'image_editor-' . $attachment->ID ); 531 | 532 | if ( ! isset( $_POST['focalPoint'] ) ) { 533 | wp_send_json_error( __( 'No focal point data received', 'hm-smart-media' ) ); 534 | } 535 | 536 | if ( empty( $_POST['focalPoint'] ) ) { 537 | delete_post_meta( $attachment->ID, '_focal_point' ); 538 | } else { 539 | $focal_point = map_deep( wp_unslash( $_POST['focalPoint'] ), 'absint' ); 540 | update_post_meta( $attachment->ID, '_focal_point', $focal_point ); 541 | } 542 | 543 | wp_send_json_success(); 544 | } 545 | 546 | /** 547 | * Output the Backbone templates for the Media Manager-based image cropping functionality. 548 | */ 549 | function templates() { 550 | include 'media-template.php'; 551 | } 552 | 553 | /** 554 | * Makes sure that the "id" (attachment ID) is valid 555 | * and dies if not. Returns attachment object with matching ID on success. 556 | * 557 | * @param string $id_param The request parameter to retrieve the ID from. 558 | * @return WP_Post 559 | */ 560 | function validate_parameters( $id_param = 'id' ) { 561 | // phpcs:ignore 562 | $attachment = get_post( intval( $_REQUEST[ $id_param ] ) ); 563 | 564 | // phpcs:ignore 565 | if ( empty( $_REQUEST[ $id_param ] ) || ! $attachment ) { 566 | // translators: %s is replaced by the text 'id' referring to the parameter name. 567 | wp_die( sprintf( esc_html__( 'Invalid %s parameter.', 'hm-smart-media' ), 'id' ) ); 568 | } 569 | 570 | if ( 'attachment' !== $attachment->post_type || ! wp_attachment_is_image( $attachment->ID ) ) { 571 | wp_die( sprintf( esc_html__( 'That is not a valid image attachment.', 'hm-smart-media' ), 'id' ) ); 572 | } 573 | 574 | if ( ! current_user_can( get_post_type_object( $attachment->post_type )->cap->edit_post, $attachment->ID ) ) { 575 | wp_die( esc_html__( 'You are not allowed to edit this attachment.', 'hm-smart-media' ) ); 576 | } 577 | 578 | return $attachment; 579 | } 580 | 581 | /** 582 | * Returns the width and height of a given thumbnail size. 583 | * 584 | * @param string $size Thumbnail size name. 585 | * @return array|false Associative array of width and height in pixels. False on invalid size. 586 | */ 587 | function get_thumbnail_dimensions( $size ) { 588 | global $_wp_additional_image_sizes; 589 | 590 | switch ( $size ) { 591 | case 'thumbnail': 592 | case 'medium': 593 | case 'medium_large': 594 | case 'large': 595 | $width = get_option( $size . '_size_w' ); 596 | $height = get_option( $size . '_size_h' ); 597 | break; 598 | default: 599 | if ( empty( $_wp_additional_image_sizes[ $size ] ) ) { 600 | return false; 601 | } 602 | 603 | $width = $_wp_additional_image_sizes[ $size ]['width']; 604 | $height = $_wp_additional_image_sizes[ $size ]['height']; 605 | } 606 | 607 | // Just to be safe 608 | $width = (int) $width; 609 | $height = (int) $height; 610 | 611 | return [ 612 | 'width' => $width, 613 | 'height' => $height, 614 | ]; 615 | } 616 | 617 | /** 618 | * Gets all image sizes as keyed array with width, height and crop values. 619 | * 620 | * @return array 621 | */ 622 | function get_image_sizes() { 623 | global $_wp_additional_image_sizes; 624 | 625 | $sizes = \get_intermediate_image_sizes(); 626 | $sizes = array_combine( $sizes, $sizes ); 627 | 628 | // Extract dimensions and crop setting. 629 | $sizes = array_map( 630 | function ( $size ) use ( $_wp_additional_image_sizes ) { 631 | if ( isset( $_wp_additional_image_sizes[ $size ] ) ) { 632 | $width = intval( $_wp_additional_image_sizes[ $size ]['width'] ); 633 | $height = intval( $_wp_additional_image_sizes[ $size ]['height'] ); 634 | $crop = (bool) $_wp_additional_image_sizes[ $size ]['crop']; 635 | } else { 636 | $width = intval( get_option( "{$size}_size_w" ) ); 637 | $height = intval( get_option( "{$size}_size_h" ) ); 638 | $crop = (bool) get_option( "{$size}_crop" ); 639 | } 640 | 641 | return [ 642 | 'width' => $width, 643 | 'height' => $height, 644 | 'crop' => $crop, 645 | 'orientation' => $width >= $height ? 'landscape' : 'portrait', 646 | ]; 647 | }, $sizes 648 | ); 649 | 650 | return $sizes; 651 | } 652 | 653 | /** 654 | * Deletes the coordinates for a custom crop for a given attachment ID and thumbnail size. 655 | * 656 | * @param int $attachment_id Attachment ID. 657 | * @param string $size Thumbnail size name. 658 | * @return bool False on failure (probably no such custom crop), true on success. 659 | */ 660 | function delete_coordinates( $attachment_id, $size ) { 661 | return delete_post_meta( $attachment_id, "_crop_{$size}" ); 662 | } 663 | 664 | /** 665 | * Force 100% quality for image edits as we only allow editing the original. 666 | * 667 | * @param int $quality Percentage quality from 0-100. 668 | * @param string $context The context for the change in jpeg quality. 669 | * @return int 670 | */ 671 | function jpeg_quality( $quality, $context = '' ) { 672 | if ( $context === 'edit_image' ) { 673 | return 100; 674 | } 675 | 676 | return $quality; 677 | } 678 | 679 | /** 680 | * Filter the default tachyon URL args. 681 | * 682 | * @param array $args 683 | * @return array 684 | */ 685 | function tachyon_args( $args ) { 686 | // Use smart cropping by default for resizes. 687 | if ( isset( $args['resize'] ) && ! isset( $args['crop'] ) ) { 688 | $args['crop_strategy'] = 'smart'; 689 | } 690 | 691 | return $args; 692 | } 693 | 694 | /** 695 | * Remove crop data when editing original. 696 | */ 697 | function on_edit_image() { 698 | // Get the attachment. 699 | $attachment = validate_parameters( 'postid' ); 700 | 701 | check_ajax_referer( 'image_editor-' . $attachment->ID ); 702 | 703 | // Only run on a save operation. 704 | if ( isset( $_POST['do'] ) && ! in_array( $_POST['do'], [ 'save', 'restore' ], true ) ) { 705 | return; 706 | } 707 | 708 | // Only run if transformations being applied. 709 | if ( $_POST['do'] === 'save' && ( ! isset( $_POST['history'] ) || empty( json_decode( wp_unslash( $_POST['history'] ), true ) ) ) ) { 710 | return; 711 | } 712 | 713 | // Remove crops as dimensions / orientation may have changed. 714 | foreach ( array_keys( get_image_sizes() ) as $size ) { 715 | delete_coordinates( $attachment->ID, $size ); 716 | } 717 | 718 | // Remove focal point. 719 | delete_post_meta( $attachment->ID, '_focal_point' ); 720 | 721 | // @todo update crop coordinates according to history steps 722 | // @todo update focal point coordinates according to history steps 723 | } 724 | 725 | /** 726 | * Prevents WordPress generated resized thumbnails for an image. 727 | * We let tachyon handle this. 728 | * 729 | * @param array $sizes 730 | * @return array 731 | */ 732 | function prevent_thumbnail_generation( $sizes ) { 733 | if ( ! function_exists( 'tachyon_url' ) ) { 734 | return $sizes; 735 | } 736 | 737 | return []; 738 | } 739 | 740 | /** 741 | * Fake attachment meta data to include all image sizes. 742 | * 743 | * This attempts to fix two issues: 744 | * - "new" image sizes are not included in meta data. 745 | * - when using Tachyon in admin and disabling resizing, 746 | * NO image sizes are included in the meta data. 747 | * 748 | * @TODO Work out how to name files if crop on upload is reintroduced. 749 | * 750 | * @param $data array The original attachment meta data. 751 | * @param $attachment_id int The attachment ID. 752 | * 753 | * @return array The modified attachment data including "new" image sizes. 754 | */ 755 | function filter_attachment_meta_data( $data, $attachment_id ) { 756 | // Save time, only calculate once. 757 | static $cache = []; 758 | 759 | if ( ! empty( $cache[ $attachment_id ] ) ) { 760 | return $cache[ $attachment_id ]; 761 | } 762 | 763 | // Only modify if valid format and for images. 764 | if ( ! is_array( $data ) || ! wp_attachment_is_image( $attachment_id ) ) { 765 | return $data; 766 | } 767 | 768 | if ( empty( $data['file'] ) ) { 769 | return $data; 770 | } 771 | 772 | if ( skip_attachment( $attachment_id ) ) { 773 | return $data; 774 | } 775 | 776 | $data = massage_meta_data_for_orientation( $data ); 777 | 778 | // Full size image info. 779 | $image_sizes = get_image_sizes(); 780 | $mime_type = get_post_mime_type( $attachment_id ); 781 | $filename = pathinfo( $data['file'], PATHINFO_FILENAME ); 782 | $ext = pathinfo( $data['file'], PATHINFO_EXTENSION ); 783 | $orig_w = $data['width']; 784 | $orig_h = $data['height']; 785 | 786 | foreach ( $image_sizes as $size => $crop ) { 787 | if ( isset( $data['sizes'][ $size ] ) ) { 788 | // Meta data is set. 789 | continue; 790 | } 791 | 792 | if ( 'full' === $size ) { 793 | // Full is a special case. 794 | continue; 795 | } 796 | 797 | /* 798 | * $new_dims = [ 799 | * 0 => 0 800 | * 1 => 0 801 | * 2 => // Crop start X axis 802 | * 3 => // Crop start Y axis 803 | * 4 => // New width 804 | * 5 => // New height 805 | * 6 => // Crop width on source image 806 | * 7 => // Crop height on source image 807 | * ]; 808 | */ 809 | $new_dims = image_resize_dimensions( $orig_w, $orig_h, $crop['width'], $crop['height'], $crop['crop'] ); 810 | 811 | if ( ! $new_dims ) { 812 | continue; 813 | } 814 | 815 | $w = (int) $new_dims[4]; 816 | $h = (int) $new_dims[5]; 817 | 818 | // Set crop hash if source crop isn't 0,0,orig_width,orig_height 819 | $crop_details = "{$orig_w},{$orig_h},{$new_dims[2]},{$new_dims[3]},{$new_dims[6]},{$new_dims[7]}"; 820 | $crop_hash = ''; 821 | 822 | if ( $crop_details !== "{$orig_w},{$orig_h},0,0,{$orig_w},{$orig_h}" ) { 823 | /* 824 | * NOTE: Custom file name data. 825 | * 826 | * The crop hash is used to help determine the correct crop to use for identically 827 | * sized images. 828 | */ 829 | $crop_hash = '-c' . substr( strtolower( sha1( $crop_details ) ), 0, 8 ); 830 | } 831 | 832 | // Add meta data with fake WP style file name. 833 | $data['sizes'][ $size ] = [ 834 | '_tachyon_dynamic' => true, 835 | 'width' => $w, 836 | 'height' => $h, 837 | 'file' => "{$filename}{$crop_hash}-{$w}x{$h}.{$ext}", 838 | 'mime-type' => $mime_type, 839 | ]; 840 | } 841 | 842 | $cache[ $attachment_id ] = $data; 843 | return $data; 844 | } 845 | 846 | /** 847 | * When saving the attachment metadata remove the dynamic sizes added 848 | * by the above filter. 849 | * 850 | * @param array $data The image metadata array. 851 | * @return array 852 | */ 853 | function filter_update_attachment_meta_data( array $data ) : array { 854 | if ( ! isset( $data['sizes'] ) ) { 855 | return $data; 856 | } 857 | foreach ( $data['sizes'] as $size => $size_data ) { 858 | if ( isset( $size_data['_tachyon_dynamic'] ) ) { 859 | unset( $data['sizes'][ $size ] ); 860 | } 861 | } 862 | return $data; 863 | } 864 | 865 | /** 866 | * Swap width and height if required. 867 | * 868 | * The Tachyon service/sharp library will automatically fix 869 | * the orientation but as a result, the width and height will 870 | * be the reverse of that calculated on upload. 871 | * 872 | * This swaps the width and height if needed but it does not 873 | * fix the image on upload as we have Tachy for that. 874 | * 875 | * @param array $meta_data Meta data stored in the database. 876 | * 877 | * @return array Meta data with correct width and height. 878 | */ 879 | function massage_meta_data_for_orientation( array $meta_data ) { 880 | if ( empty( $meta_data['image_meta']['orientation'] ) ) { 881 | // No orientation data to fix. 882 | return $meta_data; 883 | } 884 | 885 | $fix_width_height = false; 886 | 887 | switch ( $meta_data['image_meta']['orientation'] ) { 888 | case 5: 889 | case 6: 890 | case 7: 891 | case 8: 892 | case 9: 893 | $fix_width_height = true; 894 | break; 895 | } 896 | 897 | if ( ! $fix_width_height ) { 898 | return $meta_data; 899 | } 900 | 901 | $width = $meta_data['height']; 902 | $meta_data['height'] = $meta_data['width']; 903 | $meta_data['width'] = $width; 904 | unset( $meta_data['image_meta']['orientation'] ); 905 | return $meta_data; 906 | } 907 | 908 | /** 909 | * Check if this image matches the tachyon host and path. Allows subdomains. 910 | * 911 | * @param string $image Image HTML or URL. 912 | * @return boolean 913 | */ 914 | function is_tachyon_url( string $image ) : bool { 915 | if ( ! defined( 'TACHYON_URL' ) ) { 916 | return false; 917 | } 918 | 919 | // TACHYON_URL can be filtered on output so this is the only reliable method to 920 | // check an image is handled by Tachyon. 921 | $uploads_dir = wp_upload_dir(); 922 | $tachyon_base_url = dirname( tachyon_url( $uploads_dir['baseurl'] . '/image.jpg' ) ); 923 | 924 | return strpos( $image, $tachyon_base_url ) !== false; 925 | } 926 | 927 | /** 928 | * Add our special handlers for width & height attrs and srcset attributes. 929 | * 930 | * @param string $filtered_image Full img tag with attributes that will replace the source img tag. 931 | * @param string $context Additional context, like the current filter name or the function name from where this was called. 932 | * @param int $attachment_id The image attachment ID. May be 0 in case the image is not an attachment. 933 | * @return string Full img tag with attributes that will replace the source img tag. 934 | */ 935 | function content_img_tag( string $filtered_image, string $context, int $attachment_id ) : string { 936 | if ( ! is_tachyon_url( $filtered_image ) ) { 937 | return $filtered_image; 938 | } 939 | 940 | if ( $attachment_id === 0 ) { 941 | return $filtered_image; 942 | } 943 | 944 | $image_meta = wp_get_attachment_metadata( $attachment_id ); 945 | 946 | // Add 'width' and 'height' attributes if applicable. 947 | if ( ! str_contains( $filtered_image, ' width=' ) && ! str_contains( $filtered_image, ' height=' ) ) { 948 | $filtered_image = add_width_and_height_attr( $filtered_image, $image_meta ); 949 | } 950 | 951 | // Add 'srcset' and 'sizes' attributes if applicable. 952 | if ( ! str_contains( $filtered_image, ' srcset=' ) ) { 953 | $filtered_image = add_srcset_and_sizes_attr( $filtered_image, $image_meta, $attachment_id ); 954 | } 955 | 956 | // Call core function to add loading optimization attributes again. 957 | // These rely on width/heights being set correctly which is not set at the point core calls them. 958 | // See wp_img_tag_add_auto_sizes 959 | $filtered_image = wp_img_tag_add_loading_optimization_attrs( $filtered_image, $context ); 960 | 961 | // Availabile in WP 6.7 only. 962 | if ( function_exists( 'wp_img_tag_add_auto_sizes' ) ) { 963 | $filtered_image = wp_img_tag_add_auto_sizes( $filtered_image ); 964 | } 965 | 966 | return $filtered_image; 967 | } 968 | 969 | /** 970 | * Filters whether to add various attributes to the img tag markup. 971 | * 972 | * We override this to ensure compatibility with Tachyon & smart media. 973 | * 974 | * @param bool $value The filtered value, defaults to true. 975 | * @param string $image The HTML img tag where the attribute should be added. 976 | * @param string $context Additional context about how the function was called or where the img tag is. 977 | * @param int $attachment_id The image attachment ID. 978 | * @return bool The filtered value, defaults to true. 979 | */ 980 | function img_tag_add_attr( bool $value, string $image ) : bool { 981 | return ! is_tachyon_url( $image ) ? $value : false; 982 | } 983 | 984 | /** 985 | * Get the image dimensions from the img src attribute. 986 | * 987 | * @TODO Deal with edit hashes by getting the previous version of the meta 988 | * data if required for calculating the srcset using the meta value of 989 | * `_wp_attachment_backup_sizes`. To get the edit hash, refer to 990 | * wp/wp-includes/media.php:1380 991 | * 992 | * @param string $image_src The extracted image src. 993 | * @param array $image_meta The attachment meta data. 994 | * @return false|array Returns an array of [width, height] on success, false on failure. 995 | */ 996 | function get_img_src_dimensions( $image_src, $image_meta ) { 997 | if ( empty( $image_meta ) || empty( $image_meta['file'] ) ) { 998 | return false; 999 | } 1000 | 1001 | // Bail early if an image has been inserted and later edited. 1002 | list( $image_path ) = explode( '?', $image_src ); 1003 | if ( preg_match( '/-e[0-9]{13}/', $image_meta['file'], $img_edit_hash ) && 1004 | strpos( wp_basename( $image_path ), $img_edit_hash[0] ) === false ) { 1005 | return false; 1006 | } 1007 | 1008 | $width = false; 1009 | $height = false; 1010 | 1011 | parse_str( html_entity_decode( wp_parse_url( $image_src, PHP_URL_QUERY ) ?? '' ), $tachyon_args ); 1012 | 1013 | // Need to work back width and height from various Tachyon options. 1014 | if ( isset( $tachyon_args['resize'] ) ) { 1015 | // Image is cropped. 1016 | list( $width, $height ) = explode( ',', $tachyon_args['resize'] ); 1017 | } elseif ( isset( $tachyon_args['fit'] ) ) { 1018 | // Image is uncropped. 1019 | list( $width, $height ) = explode( ',', $tachyon_args['fit'] ); 1020 | list( $width, $height ) = wp_constrain_dimensions( $image_meta['width'], $image_meta['height'], $width, $height ?? 0 ); 1021 | } else { 1022 | if ( isset( $tachyon_args['w'] ) ) { 1023 | $width = (int) $tachyon_args['w']; 1024 | } 1025 | if ( isset( $tachyon_args['h'] ) ) { 1026 | $height = (int) $tachyon_args['h']; 1027 | } 1028 | if ( ! $width && ! $height ) { 1029 | $width = $image_meta['width'] ?: false; 1030 | $height = $image_meta['height'] ?: false; 1031 | } 1032 | if ( $width && ! $height ) { 1033 | list( $width, $height ) = wp_constrain_dimensions( $image_meta['width'], $image_meta['height'], $width ); 1034 | } elseif ( ! $width && $height ) { 1035 | list( $width, $height ) = wp_constrain_dimensions( $image_meta['width'], $image_meta['height'], 0, $height ); 1036 | } 1037 | } 1038 | 1039 | // Still stumped? 1040 | if ( ! $width || ! $height ) { 1041 | return false; 1042 | } 1043 | 1044 | return [ $width, $height ]; 1045 | } 1046 | 1047 | /** 1048 | * Filters the default method for getting image dimensions. 1049 | * 1050 | * @param array $dimensions List of width and height dimensions. 1051 | * @param string $image_src The current image src URL. 1052 | * @param array $image_meta Attachment metadata. 1053 | * @return void 1054 | */ 1055 | function src_get_dimensions( $dimensions, $image_src, $image_meta ) { 1056 | return get_img_src_dimensions( $image_src, $image_meta ) ?: $dimensions; 1057 | } 1058 | 1059 | /** 1060 | * Adds 'width' and 'height' attributes to an existing 'img' element. 1061 | * 1062 | * @param string $image The extracted image tag. 1063 | * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'. 1064 | * 1065 | * @return string Converted 'img' element with 'srcset' and 'sizes' attributes added. 1066 | */ 1067 | function add_width_and_height_attr( $image, $image_meta ) : string { 1068 | if ( empty( $image_meta ) ) { 1069 | return $image; 1070 | } 1071 | 1072 | $image_src = preg_match( '/src="([^"]+)"/', $image, $match_src ) ? $match_src[1] : ''; 1073 | 1074 | // Return early if we couldn't get the image source. 1075 | if ( ! $image_src ) { 1076 | return $image; 1077 | } 1078 | 1079 | // Calculate width & height. 1080 | $size_array = get_img_src_dimensions( $image_src, $image_meta ); 1081 | if ( ! $size_array ) { 1082 | return $image; 1083 | } 1084 | 1085 | // Make absolutely sure that height and width attributes are accurate. 1086 | list( $width, $height ) = wp_constrain_dimensions( $image_meta['width'], $image_meta['height'], $size_array[0], $size_array[1] ); 1087 | 1088 | $hw = trim( image_hwstring( $width, $height ) ); 1089 | return str_replace( ']+?)[\/ ]*>/', '', $image ); 1141 | } 1142 | 1143 | return $image; 1144 | } 1145 | 1146 | /** 1147 | * Return a list of modifiers for calculating image srcset and sizes from. 1148 | * 1149 | * @return array 1150 | */ 1151 | function get_image_size_modifiers( ?int $attachment_id = null ) : array { 1152 | /** 1153 | * Filters the default image size modifiers. By default 1154 | * the srcset will contain a 2x, 1.5x, 0.5x and 0.25x version of the image. 1155 | * 1156 | * @param array $modifiers The zoom values for the srcset. 1157 | * @param int|null $attachment_id The attachment ID or null. 1158 | */ 1159 | $modifiers = apply_filters( 'hm.smart-media.image-size-modifiers', [ 2, 1.5, 0.5, 0.25 ], $attachment_id ); 1160 | 1161 | // Ensure original size is part of srcset as some browsers won't use original 1162 | // size if any srcset values are present. 1163 | $modifiers[] = 1; 1164 | $modifiers = array_unique( $modifiers ); 1165 | 1166 | // Sort from highest to lowest. 1167 | rsort( $modifiers ); 1168 | return $modifiers; 1169 | } 1170 | 1171 | /** 1172 | * Update the sources array to return tachyon URLs that respect 1173 | * requested image aspect ratio and crop data. 1174 | * 1175 | * @param array $sources Array of source URLs and widths to generate the srcset attribute from. 1176 | * @param array $size_array Width and height of the original image. 1177 | * @param string $image_src The requested image URL. 1178 | * @param array $image_meta The image meta data array. 1179 | * @param integer $attachment_id The image ID. 1180 | * @return array 1181 | */ 1182 | function image_srcset( array $sources, array $size_array, string $image_src, array $image_meta, int $attachment_id ) : array { 1183 | 1184 | list( $width, $height ) = array_map( 'absint', $size_array ); 1185 | 1186 | // Ensure this is _not_ a tachyon image, not always the case when parsing from post content. 1187 | if ( ! is_tachyon_url( $image_src ) ) { 1188 | // If the aspect ratio requested matches a custom crop size, pull that 1189 | // crop (in case there's a user custom crop). Otherwise just use the 1190 | // given dimensions. 1191 | $size = [ $width, $height ]; 1192 | // Avoid errors if either dimension is zero, natural aspect ratio requested so no crop. 1193 | if ( $width && $height ) { 1194 | $size = nearest_defined_crop_size( $width / $height ) ?: $size; 1195 | } 1196 | 1197 | // Get the tachyon URL for this image size. 1198 | $image_src = wp_get_attachment_image_url( $attachment_id, $size ); 1199 | } 1200 | 1201 | // Multipliers for output srcset. 1202 | $modifiers = get_image_size_modifiers( $attachment_id ); 1203 | 1204 | // Replace sources array. 1205 | $sources = []; 1206 | 1207 | // Resize method. 1208 | $method = 'resize'; 1209 | preg_match( '/(fit|resize|lb)=/', $image_src, $matches ); 1210 | if ( isset( $matches[1] ) ) { 1211 | $method = $matches[1]; 1212 | } 1213 | 1214 | foreach ( $modifiers as $modifier ) { 1215 | $target_width = round( $width * $modifier ); 1216 | 1217 | // Do not append a srcset size larger than the original. 1218 | if ( $target_width > $image_meta['width'] ) { 1219 | continue; 1220 | } 1221 | 1222 | // Apply zoom to the image to get automatic quality adjustment. 1223 | $zoomed_image_url = add_query_arg( [ 1224 | 'w' => false, 1225 | 'h' => false, 1226 | $method => rawurlencode( "{$width},{$height}" ), 1227 | 'zoom' => rawurlencode( $modifier ), 1228 | ], $image_src ); 1229 | 1230 | // Append the new target width to the sources array. 1231 | $sources[ $target_width ] = [ 1232 | 'url' => $zoomed_image_url, 1233 | 'descriptor' => 'w', 1234 | 'value' => $target_width, 1235 | ]; 1236 | } 1237 | 1238 | // Sort by keys largest to smallest. 1239 | krsort( $sources ); 1240 | 1241 | return $sources; 1242 | } 1243 | 1244 | /** 1245 | * Returns the closest defined crop size to a given ratio. 1246 | * 1247 | * If there is a theme crop defined with proportians similar enough to the 1248 | * source image,returns the name of that crop size. Otherwise, returns "full". 1249 | * 1250 | * @param float $ratio Width to height ratio of an image. 1251 | * @return string|null Closest defined image size to that ratio; null if none match. 1252 | */ 1253 | function nearest_defined_crop_size( $ratio ) { 1254 | // Get only the custom image sizes that are croppable. 1255 | $croppable_sizes = array_filter( wp_get_additional_image_sizes(), function ( $size ) { 1256 | return absint( $size['width'] ) && absint( $size['height'] ); 1257 | } ); 1258 | /* 1259 | * Compare each of the theme crops to the ratio in question. Returns a 1260 | * sort-of difference where 0 is identical. Not mathematically meaningful 1261 | * at scale, but good enough for checking if something is within 2%. 1262 | */ 1263 | $difference_from_theme_crop_ratios = array_map( 1264 | /** 1265 | * Get the difference between the aspect ratio of a given crop size and an expected ratio. 1266 | * 1267 | * @param [] $crop_data Image size definition for a custom crop size. 1268 | * @return float Difference between expected and actual aspect ratios: 0 = identical, 1.0 = +-100%. 1269 | */ 1270 | function( $crop_data ) use ( $ratio ) { 1271 | $crop_ratio = ( $crop_data['width'] / $crop_data['height'] ); 1272 | return abs( $crop_ratio / $ratio - 1 ); 1273 | }, 1274 | // ... of all the custom image sizes defined. 1275 | $croppable_sizes 1276 | ); 1277 | // Sort the differences from most to least similar. 1278 | asort( $difference_from_theme_crop_ratios, SORT_NUMERIC ); 1279 | /* 1280 | * If the most similar crop from the defined image sizes is within 2% of 1281 | * the requested dimensions, use it. Otherwise just treat this as an 1282 | * uncropped image and use the full size image. 1283 | */ 1284 | return ( current( $difference_from_theme_crop_ratios ) < 0.02 ) ? 1285 | key( $difference_from_theme_crop_ratios ) : null; 1286 | } 1287 | 1288 | /** 1289 | * Ignore the $content_width global in the display context. 1290 | * 1291 | * @param array $size_array 1292 | * @param string|array $size 1293 | * @return array 1294 | */ 1295 | function editor_max_image_size( array $size_array, $size ) : array { 1296 | 1297 | if ( is_array( $size ) ) { 1298 | return $size; 1299 | } 1300 | 1301 | $sizes = get_image_sizes(); 1302 | 1303 | if ( ! isset( $sizes[ $size ] ) ) { 1304 | return $size_array; 1305 | } 1306 | 1307 | return [ 1308 | $sizes[ $size ]['width'], 1309 | $sizes[ $size ]['height'], 1310 | ]; 1311 | } 1312 | 1313 | /** 1314 | * Filter wp_image_file_matches_image_meta() to add support for URL encoded images. 1315 | * 1316 | * In some cases Tachyon or smart media can return or store a URL encoded image src. 1317 | * WordPress checks the passed image URL against the image metadata as a safety check 1318 | * when editing images inline but it does not account for URL encoded paths. 1319 | * 1320 | * @param bool $match Whether the image has matched or not. 1321 | * @param string $image_location The image URL to compare. 1322 | * @param array $image_meta Attachment meta data. 1323 | * @param int $attachment_id The attachment ID. 1324 | * @return bool 1325 | */ 1326 | function image_file_matches_image_meta( bool $match, string $image_location, array $image_meta, int $attachment_id ) : bool { 1327 | // Return found matches immediately. 1328 | if ( $match ) { 1329 | return $match; 1330 | } 1331 | 1332 | // Bail if we've already checked this URL. 1333 | if ( isset( $image_meta['is_url_decoded'] ) ) { 1334 | return $match; 1335 | } 1336 | 1337 | // Ignore any URLs that definitely don't contain URL encoded characters. 1338 | if ( strpos( $image_location, '%' ) === false ) { 1339 | return $match; 1340 | } 1341 | 1342 | // URL decode the image src. 1343 | $image_location = urldecode( $image_location ); 1344 | 1345 | // Add a flag to image meta to avoid recursion. 1346 | $image_meta['is_url_decoded'] = true; 1347 | 1348 | // Check again for a match. 1349 | return wp_image_file_matches_image_meta( $image_location, $image_meta, $attachment_id ); 1350 | } 1351 | -------------------------------------------------------------------------------- /inc/cropper/src/cropper.js: -------------------------------------------------------------------------------- 1 | import { addAction, applyFilters, addFilter } from '@wordpress/hooks'; 2 | import { __ } from '@wordpress/i18n'; 3 | import Media from '@wordpress/media'; 4 | import template from '@wordpress/template'; 5 | import ImageEditView from './views/image-edit'; 6 | 7 | import './cropper.scss'; 8 | 9 | // Register attachment to attribute map for core blocks. 10 | addFilter( 11 | 'smartmedia.cropper.updateBlockAttributesOnSelect.core.image', 12 | 'smartmedia/cropper/update-block-on-select/core/image', 13 | ( attributes, image ) => { 14 | // Only user selectable image sizes have a label so return early if this is missing. 15 | if ( ! image.label ) { 16 | return attributes; 17 | } 18 | 19 | return { 20 | sizeSlug: image.size, 21 | url: image.url, 22 | }; 23 | } 24 | ); 25 | 26 | addFilter( 27 | 'smartmedia.cropper.selectSizeFromBlockAttributes.core.image', 28 | 'smartmedia/cropper/select-size-from-block-attributes/core/image', 29 | ( size, block ) => { 30 | return size || block.attributes.sizeSlug || 'full'; 31 | } 32 | ); 33 | 34 | // Ensure Smart Media is supported by Asset Manager Framework. 35 | addAction( 'amf.extend_toolbar', 'smartmedia/cropper', extend_toolbar => { 36 | Media.view.Toolbar = extend_toolbar( Media.view.Toolbar, 'apply' ); 37 | } ); 38 | 39 | 40 | // Ensure blocks are deselected when focusing or clicking into the meta boxes. 41 | if ( wp && wp.data && window._wpLoadBlockEditor ) { 42 | // Wait for editor to load. 43 | window._wpLoadBlockEditor.then( () => { 44 | // Ensure this is an editor page. 45 | const editor = document.querySelector( '.block-editor' ); 46 | if ( ! editor ) { 47 | return; 48 | } 49 | // Callback to deselect current block. 50 | function deselectBlocks( event ) { 51 | if ( ! event.target.closest( '.edit-post-meta-boxes-area' ) ) { 52 | return; 53 | } 54 | wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); 55 | }; 56 | editor.addEventListener( 'focusin', deselectBlocks ); 57 | } ); 58 | } 59 | 60 | 61 | // Create a high level event we can hook into for media frame creation. 62 | const MediaFrame = Media.view.MediaFrame; 63 | 64 | // Override the MediaFrame on the global - this is used for media.php. 65 | Media.view.MediaFrame = MediaFrame.extend( { 66 | initialize() { 67 | MediaFrame.prototype.initialize.apply( this, arguments ); 68 | 69 | // Fire a high level init event. 70 | Media.events.trigger( 'frame:init', this ); 71 | }, 72 | } ); 73 | 74 | // Used on edit.php 75 | const MediaFrameSelect = Media.view.MediaFrame.Select; 76 | Media.view.MediaFrame.Select = MediaFrameSelect.extend( { 77 | initialize( options ) { 78 | MediaFrameSelect.prototype.initialize.apply( this, arguments ); 79 | 80 | // Reset the button as options are updated globally and causes some setup steps not to run. 81 | this._button = Object.assign( {}, options.button || {} ); 82 | this.on( 'toolbar:create:select', this.onCreateToolbarSetButton, this ); 83 | 84 | // Add our image editor state. 85 | this.createImageEditorState(); 86 | this.on( 'ready', this.createImageEditorState, this ); 87 | 88 | // Bind edit state views. 89 | this.on( 'content:create:edit', this.onCreateImageEditorContent, this ); 90 | this.on( 'toolbar:create:edit', this.onCreateImageEditorToolbar, this ); 91 | 92 | // Fire a high level init event. 93 | Media.events.trigger( 'frame:select:init', this ); 94 | }, 95 | onCreateToolbarSetButton: function () { 96 | if ( this._button ) { 97 | this.options.mutableButton = Object.assign( {}, this.options.button ); 98 | this.options.button = Object.assign( {}, this._button ); 99 | } 100 | }, 101 | createImageEditorState: function () { 102 | // Only single selection mode is supported. 103 | if ( this.options.multiple ) { 104 | return; 105 | } 106 | 107 | // Don't add the edit state if we also have the built in cropper state. 108 | if ( this.states.get( 'cropper' ) ) { 109 | return; 110 | } 111 | 112 | // If we already have the state it's safe to ignore. 113 | if ( this.states.get( 'edit' ) ) { 114 | return; 115 | } 116 | 117 | const libraryState = this.states.get( 'library' ) || this.states.get( 'featured-image' ); 118 | if ( ! libraryState || ! libraryState.get( 'selection' ) ) { 119 | return; 120 | } 121 | 122 | const isFeaturedImage = libraryState.id === 'featured-image'; 123 | 124 | // Hide the toolbar for the library mode. 125 | this.$el.addClass( 'hide-toolbar' ); 126 | 127 | // Create new editing state. 128 | const editState = this.states.add( { 129 | id: 'edit', 130 | title: __( 'Edit image', 'hm-smart-media' ), 131 | router: false, 132 | menu: false, 133 | uploader: false, 134 | library: libraryState.get( 'library' ), 135 | selection: libraryState.get( 'selection' ), 136 | display: libraryState.get( 'display' ), 137 | } ); 138 | 139 | // Set region modes when entering and leaving edit state. 140 | editState.on( 'activate', () => { 141 | // Preserve settings from previous view. 142 | if ( this.$el.hasClass( 'hide-menu' ) && this.lastState() ) { 143 | this.lastState().set( 'menu', false ); 144 | } 145 | 146 | // Toggle edit mode on regions. 147 | this.$el.addClass( 'mode-select mode-edit-image' ); 148 | this.$el.removeClass( 'hide-toolbar' ); 149 | this.content.mode( 'edit' ); 150 | this.toolbar.mode( 'edit' ); 151 | } ); 152 | editState.on( 'deactivate', () => { 153 | this.$el.removeClass( 'mode-select mode-edit-image' ); 154 | this.$el.addClass( 'hide-toolbar' ); 155 | } ); 156 | 157 | // Handle selection events. 158 | libraryState.get( 'selection' ).on( 'selection:single', () => { 159 | const single = this.state( 'edit' ).get( 'selection' ).single(); 160 | 161 | if ( this._state === 'edit' ) { 162 | return; 163 | } 164 | 165 | // Check that the attachment is a complete object. Built in placeholders 166 | // exist for the cover block that can confuse things. 167 | if ( ! single.get( 'id' ) ) { 168 | single.fetch().then( () => { 169 | this.setState( 'edit' ); 170 | } ); 171 | return; 172 | } 173 | 174 | // Check this is an image or not. 175 | if ( single.get( 'mime' ) && ! single.get( 'mime' ).match( /^image\// ) ) { 176 | return; 177 | } 178 | 179 | // Update the placeholder the featured image frame uses to set its 180 | // default selection from. 181 | if ( isFeaturedImage ) { 182 | // Avoid updating attributes on any selected blocks. 183 | if ( wp && wp.data ) { 184 | const selectedBlock = wp.data.select( 'core/block-editor' )?.getSelectedBlock(); 185 | if ( selectedBlock ) { 186 | wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); 187 | } 188 | } 189 | 190 | // Set size back to full. 191 | single.set( { size: 'full' } ); 192 | 193 | wp.media.view.settings.post.featuredImageId = single.get( 'id' ); 194 | } 195 | 196 | this.setState( 'edit' ); 197 | } ); 198 | libraryState.get( 'selection' ).on( 'selection:unsingle', () => { 199 | // Update the placeholder the featured image frame uses to set its 200 | // default selection from. 201 | if ( isFeaturedImage ) { 202 | wp.media.view.settings.post.featuredImageId = -1; 203 | } 204 | 205 | if ( this._state !== 'edit' ) { 206 | return; 207 | } 208 | 209 | this.setState( libraryState.id ); 210 | } ); 211 | }, 212 | onCreateImageEditorContent: function ( region ) { 213 | const state = this.state( 'edit' ); 214 | const single = state.get( 'selection' ).single(); 215 | const sidebar = new Media.view.Sidebar( { 216 | controller: this, 217 | } ); 218 | 219 | // Set sidebar views. 220 | sidebar.set( 'details', new Media.view.Attachment.Details( { 221 | controller: this, 222 | model: single, 223 | priority: 80 224 | } ) ); 225 | 226 | sidebar.set( 'compat', new Media.view.AttachmentCompat( { 227 | controller: this, 228 | model: single, 229 | priority: 120 230 | } ) ); 231 | 232 | const display = state.has( 'display' ) ? state.get( 'display' ) : state.get( 'displaySettings' ); 233 | 234 | if ( display ) { 235 | sidebar.set( 'display', new Media.view.Settings.AttachmentDisplay( { 236 | controller: this, 237 | model: state.display( single ), 238 | attachment: single, 239 | priority: 160, 240 | userSettings: state.model.get( 'displayUserSettings' ) 241 | } ) ); 242 | } 243 | 244 | // Show the sidebar on mobile 245 | if ( state.id === 'insert' ) { 246 | sidebar.$el.addClass( 'visible' ); 247 | } 248 | 249 | region.view = [ 250 | new ImageEditView( { 251 | tagName: 'div', 252 | className: 'media-image-edit', 253 | controller: this, 254 | model: single, 255 | } ), 256 | sidebar, 257 | ]; 258 | }, 259 | onCreateImageEditorToolbar: function ( region ) { 260 | region.view = new Media.view.Toolbar( { 261 | controller: this, 262 | requires: { selection: true }, 263 | reset: false, 264 | event: 'select', 265 | items: { 266 | change: { 267 | text: __( 'Change image', 'hm-smart-media' ), 268 | click: () => { 269 | // Remove the current selection. 270 | this.state( 'edit' ).get( 'selection' ).reset( [] ); 271 | // this.setState( libraryState.id ); 272 | }, 273 | priority: 20, 274 | requires: { selection: true }, 275 | }, 276 | apply: { 277 | style: 'primary', 278 | text: __( 'Select', 'hm-smart-media' ), 279 | click: () => { 280 | const { close, event, reset, state } = Object.assign( this.options.mutableButton || this.options.button || {}, { 281 | event: 'select', 282 | close: true, 283 | } ); 284 | 285 | if ( close ) { 286 | this.close(); 287 | } 288 | 289 | // Trigger the event on the current state if available, falling 290 | // back to last state and finally the frame. 291 | if ( event ) { 292 | if ( this.state()._events[ event ] ) { 293 | this.state().trigger( event ); 294 | } else if ( this.lastState()._events[ event ] ) { 295 | this.lastState().trigger( event ); 296 | } else { 297 | this.trigger( event ); 298 | } 299 | } 300 | 301 | if ( state ) { 302 | this.setState( state ); 303 | } 304 | 305 | if ( reset ) { 306 | this.reset(); 307 | } 308 | 309 | // Update current block if we can map the attachment to attributes. 310 | if ( wp && wp.data ) { 311 | const selectedBlock = wp.data.select( 'core/block-editor' )?.getSelectedBlock(); 312 | if ( ! selectedBlock ) { 313 | return; 314 | } 315 | 316 | // Get the attachment data and selected image size data. 317 | const attachment = this.state( 'edit' ).get( 'selection' ).single(); 318 | 319 | if ( ! attachment ) { 320 | return; 321 | } 322 | 323 | const sizes = attachment.get( 'sizes' ); 324 | const size = attachment.get( 'size' ); 325 | 326 | const image = sizes[ size ]; 327 | image.id = attachment.get( 'id' ); 328 | image.size = size; 329 | 330 | const attributesByBlock = applyFilters( 331 | `smartmedia.cropper.updateBlockAttributesOnSelect.${ selectedBlock.name.replace( /\W+/g, '.' ) }`, 332 | null, 333 | image, 334 | attachment 335 | ); 336 | 337 | const attributesForAllBlocks = applyFilters( 338 | 'smartmedia.cropper.updateBlockAttributesOnSelect', 339 | attributesByBlock, 340 | selectedBlock, 341 | image, 342 | attachment 343 | ); 344 | 345 | // Don't update if a falsey value is returned. 346 | if ( ! attributesForAllBlocks ) { 347 | return; 348 | } 349 | 350 | wp.data.dispatch( 'core/block-editor' ).updateBlock( selectedBlock.clientId, { 351 | attributes: attributesForAllBlocks, 352 | } ); 353 | } 354 | }, 355 | priority: 10, 356 | requires: { selection: true }, 357 | }, 358 | }, 359 | } ); 360 | } 361 | } ); 362 | 363 | // Replace TwoColumn view. 364 | Media.events.on( 'frame:init', () => { 365 | Media.view.Attachment.Details.TwoColumn = Media.view.Attachment.Details.TwoColumn.extend( { 366 | template: template( 'hm-attachment-details-two-column' ), 367 | initialize() { 368 | Media.view.Attachment.Details.prototype.initialize.apply( this, arguments ); 369 | 370 | // Update on URL change eg. edit. 371 | this.listenTo( this.model, 'change:url', () => { 372 | this.render(); 373 | ImageEditView.load( this.controller ); 374 | } ); 375 | 376 | // Load ImageEditView when the frame is ready or refreshed. 377 | this.controller.on( 'ready refresh', () => ImageEditView.load( this.controller ) ); 378 | } 379 | } ); 380 | } ); 381 | 382 | // Add width & height attributes to library images for smoother loading. 383 | const MediaAttachment = Media.view.Attachment; 384 | const MediaAttachmentLibrary = Media.view.Attachment.Library; 385 | const MediaAttachmentEditLibrary = Media.view.Attachment.EditLibrary; 386 | const MediaAttachmentSelection = Media.view.Attachment.Selection; 387 | const overrideRender = function () { 388 | MediaAttachment.prototype.render.apply( this, arguments ); 389 | if ( this.model.get( 'type' ) === 'image' && ! this.model.get( 'uploading' ) ) { 390 | const size = this.imageSize(); 391 | this.$el.find( 'img' ).attr( { 392 | width: size.width, 393 | height: size.height, 394 | } ); 395 | } 396 | }; 397 | Media.view.Attachment = MediaAttachment.extend( { 398 | render: overrideRender, 399 | } ); 400 | Media.view.Attachment.Library = MediaAttachmentLibrary.extend( { 401 | render: overrideRender, 402 | } ); 403 | Media.view.Attachment.EditLibrary = MediaAttachmentEditLibrary.extend( { 404 | render: overrideRender, 405 | } ); 406 | Media.view.Attachment.Selection = MediaAttachmentSelection.extend( { 407 | render: overrideRender, 408 | } ); 409 | 410 | /** 411 | * Ensure uploader status view is actually rendered before 412 | * updating info display. 413 | */ 414 | const MediaUploaderStatus = Media.view.UploaderStatus; 415 | Media.view.UploaderStatus = MediaUploaderStatus.extend( { 416 | info: function () { 417 | if ( ! this.$index ) { 418 | return; 419 | } 420 | MediaUploaderStatus.prototype.info.apply( this, arguments ); 421 | } 422 | } ); 423 | -------------------------------------------------------------------------------- /inc/cropper/src/cropper.scss: -------------------------------------------------------------------------------- 1 | /* Hide built in image editor */ 2 | .wp-core-ui { 3 | 4 | &.mode-edit-image { 5 | .edit-attachment, 6 | .button[id^="imgedit-open-btn-"] { 7 | display: none; 8 | } 9 | } 10 | 11 | .media-image-edit { 12 | display: flex; 13 | align-items: stretch; 14 | max-height: 100%; 15 | } 16 | 17 | .media-frame.mode-edit-image { 18 | .media-image-edit { 19 | margin-right: 30%; 20 | } 21 | 22 | .media-sidebar { 23 | width: 30%; 24 | box-sizing: border-box; 25 | } 26 | } 27 | 28 | .hm-thumbnail-sizes { 29 | flex: 0 0 200px; 30 | max-height: 100%; 31 | overflow: auto; 32 | background: #e5e5e5; 33 | 34 | h2 { 35 | margin: 16px; 36 | padding: 0; 37 | } 38 | 39 | &__list { 40 | margin: 0; 41 | padding: 0; 42 | 43 | li { 44 | width: 100%; 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | li:first-child { 50 | button { 51 | border-top: 0; 52 | } 53 | } 54 | 55 | button { 56 | background: none; 57 | border: 0; 58 | border-right: 1px solid #ddd; 59 | margin: 0; 60 | padding: 16px; 61 | box-sizing: border-box; 62 | cursor: pointer; 63 | display: block; 64 | width: 100%; 65 | text-align: left; 66 | } 67 | 68 | button.current { 69 | border: 1px solid #ddd; 70 | border-width: 1px 0; 71 | padding: 15px 16px; 72 | background: #fff; 73 | position: relative; 74 | } 75 | 76 | h3 { 77 | margin: 0 0 8px; 78 | padding: 0; 79 | 80 | small { 81 | font-weight: 300; 82 | white-space: nowrap; 83 | } 84 | } 85 | 86 | img { 87 | display: block; 88 | width: auto; 89 | height: auto; 90 | max-width: 100%; 91 | max-height: 80px; 92 | } 93 | } 94 | } 95 | 96 | .hm-thumbnail-editor { 97 | padding: 16px; 98 | overflow: auto; 99 | flex: 1; 100 | 101 | h2 { 102 | margin: 0 0 16px; 103 | 104 | small { 105 | font-weight: normal; 106 | white-space: nowrap; 107 | } 108 | } 109 | 110 | .imgedit-menu { 111 | p { 112 | margin-bottom: 0; 113 | font-size: 16px; 114 | } 115 | button::before { 116 | margin-left: 8px; 117 | } 118 | } 119 | 120 | &__image-wrap { 121 | overflow: hidden; 122 | } 123 | 124 | &__image { 125 | float: left; 126 | position: relative; 127 | 128 | &-crop { 129 | position: relative; 130 | padding: 0; 131 | margin: 10px 0 0; 132 | } 133 | 134 | &--preview { 135 | float: none; 136 | } 137 | 138 | img { 139 | display: block; 140 | max-width: 100%; 141 | max-height: 500px; 142 | width: auto; 143 | height: auto; 144 | } 145 | 146 | img[src$=".svg"] { 147 | width: 100%; 148 | } 149 | 150 | .image-preview-full { 151 | cursor: crosshair; 152 | } 153 | } 154 | 155 | &__actions { 156 | margin: 16px 0 8px; 157 | } 158 | 159 | .imgedit-wait { 160 | position: static; 161 | width: 20px; 162 | height: 20px; 163 | vertical-align: middle; 164 | float: right; 165 | margin: 4px 0 4px 10px; 166 | 167 | &::before { 168 | margin: 0; 169 | position: static; 170 | } 171 | } 172 | 173 | &__focal-point { 174 | position: absolute; 175 | box-sizing: border-box; 176 | width: 80px; 177 | height: 80px; 178 | margin-left: -40px; 179 | margin-top: -40px; 180 | left: 0; 181 | top: 0; 182 | background: rgba(200,125,125,.5); 183 | border: 2.5px solid rgba(200,50,50,.5); 184 | border-radius: 200px; 185 | cursor: cell; 186 | display: none; 187 | } 188 | 189 | .imgareaselect-outer { 190 | position: absolute !important; 191 | } 192 | 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /inc/cropper/src/utils.js: -------------------------------------------------------------------------------- 1 | import { clone } from 'lodash'; 2 | import BackBone from '@wordpress/backbone'; 3 | 4 | /** 5 | * Serialises nested collections and models 6 | * 7 | * @param object 8 | * @returns {*} 9 | */ 10 | export const toJSONDeep = object => { 11 | let json = clone( object.attributes ); 12 | for ( let attr in json ) { 13 | if ( ( json[ attr ] instanceof Backbone.Model ) || ( json[ attr ] instanceof Backbone.Collection ) ) { 14 | json[ attr ] = json[ attr ].toJSON(); 15 | } 16 | } 17 | return json; 18 | } 19 | 20 | /** 21 | * Get the maximum crop size within in a bounding box. 22 | * 23 | * @param {int} width 24 | * @param {int} height 25 | * @param {int} cropWidth 26 | * @param {int} cropHeight 27 | * @returns {array} 28 | */ 29 | export const getMaxCrop = ( width, height, cropWidth, cropHeight ) => { 30 | const maxHeight = width / cropWidth * cropHeight; 31 | 32 | if ( maxHeight < height ) { 33 | return [ width, Math.round( maxHeight ) ]; 34 | } 35 | 36 | return [ Math.round( height / cropHeight * cropWidth ), height ]; 37 | } 38 | -------------------------------------------------------------------------------- /inc/cropper/src/views/image-edit-sizes.js: -------------------------------------------------------------------------------- 1 | import Media from '@wordpress/media'; 2 | import template from '@wordpress/template'; 3 | 4 | /** 5 | * Image size selector. 6 | */ 7 | const ImageEditSizes = Media.View.extend( { 8 | tagName: 'div', 9 | className: 'hm-thumbnail-sizes', 10 | template: template( 'hm-thumbnail-sizes' ), 11 | events: { 12 | 'click button': 'setSize', 13 | }, 14 | initialize() { 15 | this.listenTo( this.model, 'change:sizes', this.render ); 16 | this.listenTo( this.model, 'change:uploading', this.render ); 17 | if ( ! this.model.get( 'size' ) ) { 18 | this.model.set( { size: 'full' } ); 19 | } 20 | this.on( 'ready', () => { 21 | const current = this.el.querySelector( '.current' ); 22 | if ( current ) { 23 | current.scrollIntoView(); 24 | } 25 | } ); 26 | }, 27 | setSize( e ) { 28 | this.model.set( { size: e.currentTarget.dataset.size } ); 29 | e.currentTarget.parentNode.parentNode.querySelectorAll( 'button' ).forEach( button => { 30 | button.className = ''; 31 | } ); 32 | e.currentTarget.className = 'current'; 33 | }, 34 | } ); 35 | 36 | export default ImageEditSizes; 37 | -------------------------------------------------------------------------------- /inc/cropper/src/views/image-edit.js: -------------------------------------------------------------------------------- 1 | import { applyFilters } from '@wordpress/hooks'; 2 | import Media from '@wordpress/media'; 3 | import template from '@wordpress/template'; 4 | import ImageEditSizes from './image-edit-sizes'; 5 | import ImageEditor from './image-editor'; 6 | import ImagePreview from './image-preview'; 7 | 8 | /** 9 | * The main image editor content area. 10 | */ 11 | const ImageEditView = Media.View.extend( { 12 | template: template( 'hm-thumbnail-container' ), 13 | initialize() { 14 | // Set the current size being edited. 15 | if ( ! this.model.get( 'size' ) ) { 16 | this.model.set( { size: 'full' } ); 17 | } 18 | 19 | // Get current size from block attributes if available. 20 | this.setSizeFromBlock(); 21 | 22 | // Re-render on certain updates. 23 | this.listenTo( this.model, 'change:url', this.onUpdate ); 24 | 25 | // Initial render. 26 | this.onUpdate(); 27 | }, 28 | setSizeFromBlock() { 29 | if ( ! wp || ! wp.data ) { 30 | return; 31 | } 32 | 33 | const selectedBlock = wp.data.select( 'core/block-editor' )?.getSelectedBlock(); 34 | if ( ! selectedBlock ) { 35 | return; 36 | } 37 | 38 | const sizeForBlock = applyFilters( 39 | `smartmedia.cropper.selectSizeFromBlockAttributes.${ selectedBlock.name.replace( /\W+/g, '.' ) }`, 40 | null, 41 | selectedBlock 42 | ); 43 | 44 | const size = applyFilters( 45 | 'smartmedia.cropper.selectSizeFromBlockAttributes', 46 | sizeForBlock, 47 | selectedBlock 48 | ); 49 | 50 | if ( ! size ) { 51 | return; 52 | } 53 | 54 | this.model.set( { size } ); 55 | }, 56 | onUpdate() { 57 | const views = []; 58 | 59 | // If the attachment info hasn't loaded yet show a spinner. 60 | if ( this.model.get( 'uploading' ) ) { 61 | views.push( new Media.view.UploaderStatus( { 62 | controller: this.controller, 63 | } ) ); 64 | } else if ( this.model.get( 'id' ) && ! this.model.get( 'url' ) ) { 65 | views.push( new Media.view.Spinner() ); 66 | } else { 67 | // Ensure this attachment is editable. 68 | if ( this.model.get( 'editor' ) && this.model.get( 'mime' ).match( /image\/(gif|jpe?g|png|webp)/ ) ) { 69 | views.push( new ImageEditSizes( { 70 | controller: this.controller, 71 | model: this.model, 72 | priority: 10, 73 | } ) ); 74 | views.push( new ImageEditor( { 75 | controller: this.controller, 76 | model: this.model, 77 | priority: 50, 78 | } ) ); 79 | } else { 80 | views.push( new ImagePreview( { 81 | controller: this.controller, 82 | model: this.model, 83 | priority: 50, 84 | } ) ); 85 | } 86 | } 87 | 88 | this.views.set( views ); 89 | }, 90 | } ); 91 | 92 | ImageEditView.load = ( controller ) => new ImageEditView( { 93 | controller: controller, 94 | model: controller.model, 95 | el: controller.$el.find( '.media-image-edit' ).get( 0 ), 96 | } ); 97 | 98 | export default ImageEditView; 99 | -------------------------------------------------------------------------------- /inc/cropper/src/views/image-editor.js: -------------------------------------------------------------------------------- 1 | import Media from '@wordpress/media'; 2 | import template from '@wordpress/template'; 3 | import ajax from '@wordpress/ajax'; 4 | import jQuery from 'jQuery'; 5 | import smartcrop from 'smartcrop'; 6 | import { getMaxCrop } from '../utils'; 7 | 8 | const $ = jQuery; 9 | 10 | /** 11 | * Image editor. 12 | */ 13 | const ImageEditor = Media.View.extend( { 14 | tagName: 'div', 15 | className: 'hm-thumbnail-editor', 16 | template: template( 'hm-thumbnail-editor' ), 17 | events: { 18 | 'click .button-apply-changes': 'saveCrop', 19 | 'click .button-reset': 'reset', 20 | 'click .button-remove-crop': 'removeCrop', 21 | 'click .image-preview-full': 'onClickPreview', 22 | 'click .focal-point': 'removeFocalPoint', 23 | 'click .imgedit-menu button': 'onEditImage', 24 | }, 25 | initialize() { 26 | // Re-render on size change. 27 | this.listenTo( this.model, 'change:size', this.loadEditor ); 28 | this.on( 'ready', this.loadEditor ); 29 | 30 | // Set window imageEdit._view to this and no-op built in crop tool. 31 | if ( window.imageEdit ) { 32 | window.imageEdit._view = this; 33 | window.imageEdit.initCrop = () => {}; 34 | window.imageEdit.setCropSelection = () => {}; 35 | } 36 | }, 37 | loadEditor() { 38 | // Remove any existing cropper. 39 | if ( this.cropper ) { 40 | this.cropper.setOptions( { remove: true } ); 41 | } 42 | 43 | this.render(); 44 | 45 | const size = this.model.get( 'size' ); 46 | 47 | if ( size !== 'full' && size !== 'full-orig' ) { 48 | // Load cropper if we picked a thumbnail. 49 | this.initCropper(); 50 | } else { 51 | // Load focal point UI. 52 | this.initFocalPoint(); 53 | } 54 | }, 55 | refresh() { 56 | this.update(); 57 | }, 58 | back() {}, 59 | save() { 60 | this.update(); 61 | }, 62 | update() { 63 | // Update the redux store in gutenberg. 64 | if ( wp && wp.data && wp.data.dispatch( 'core' )?.saveMedia ) { 65 | wp.data.dispatch( 'core' ).saveMedia( { 66 | id: this.model.get( 'id' ), 67 | } ); 68 | } 69 | this.model.fetch( { 70 | success: () => this.loadEditor(), 71 | error: () => {}, 72 | } ); 73 | }, 74 | applyRatio() { 75 | const ratio = this.model.get( 'width' ) / Math.min( 1000, this.model.get( 'width' ) ); 76 | return [ ...arguments ].map( dim => Math.round( dim * ratio ) ); 77 | }, 78 | reset() { 79 | const sizeName = this.model.get( 'size' ); 80 | const sizes = this.model.get( 'sizes' ); 81 | const focalPoint = this.model.get( 'focalPoint' ); 82 | const width = this.model.get( 'width' ); 83 | const height = this.model.get( 'height' ); 84 | const size = sizes[ sizeName ] || null; 85 | 86 | if ( ! size ) { 87 | return; 88 | } 89 | 90 | const crop = size.cropData; 91 | 92 | // Reset to focal point or smart crop by default. 93 | if ( ! crop.hasOwnProperty( 'x' ) ) { 94 | const [ cropWidth, cropHeight ] = getMaxCrop( width, height, size.width, size.height ); 95 | if ( focalPoint.hasOwnProperty( 'x' ) ) { 96 | this.setSelection( { 97 | x: Math.min( width - cropWidth, Math.max( 0, focalPoint.x - ( cropWidth / 2 ) ) ), 98 | y: Math.min( height - cropHeight, Math.max( 0, focalPoint.y - ( cropHeight / 2 ) ) ), 99 | width: cropWidth, 100 | height: cropHeight, 101 | } ); 102 | } else { 103 | const image = this.$el.find( 'img[id^="image-preview-"]' ).get( 0 ); 104 | smartcrop 105 | .crop( image, { 106 | width: size.width, 107 | height: size.height, 108 | } ) 109 | .then( ( { topCrop } ) => { 110 | this.setSelection( topCrop ); 111 | } ) 112 | .catch( error => { 113 | console.error( error ); 114 | // Fallback to centered crop, can fail due to cross-origin canvas tainting. 115 | this.setSelection( { 116 | x: Math.max( 0 , ( width - cropWidth ) / 2 ), 117 | y: Math.max( 0, ( height - cropHeight ) / 2 ), 118 | width: cropWidth, 119 | height: cropHeight, 120 | } ); 121 | } ); 122 | } 123 | } else { 124 | this.setSelection( crop ); 125 | } 126 | }, 127 | saveCrop() { 128 | const crop = this.cropper.getSelection(); 129 | 130 | // Disable buttons. 131 | this.onSelectStart(); 132 | 133 | // Disable the cropper. 134 | if ( this.cropper ) { 135 | this.cropper.setOptions( { disable: true } ); 136 | } 137 | 138 | // Send AJAX request to save the crop coordinates. 139 | ajax.post( 'hm_save_crop', { 140 | _ajax_nonce: this.model.get( 'nonces' ).edit, 141 | id: this.model.get( 'id' ), 142 | crop: { 143 | x: crop.x1, 144 | y: crop.y1, 145 | width: crop.width, 146 | height: crop.height, 147 | }, 148 | size: this.model.get( 'size' ), 149 | } ) 150 | // Re-enable buttons and cropper. 151 | .always( () => { 152 | this.onSelectEnd(); 153 | if ( this.cropper ) { 154 | this.cropper.setOptions( { enable: true } ); 155 | } 156 | } ) 157 | .done( () => { 158 | // Update & re-render. 159 | this.update(); 160 | } ) 161 | .fail( error => console.log( error ) ); 162 | }, 163 | setSelection( crop ) { 164 | this.onSelectStart(); 165 | 166 | if ( ! crop || typeof crop.x === 'undefined' ) { 167 | this.cropper.setOptions( { show: true } ); 168 | this.cropper.update(); 169 | return; 170 | } 171 | 172 | this.cropper.setSelection( crop.x, crop.y, crop.x + crop.width, crop.y + crop.height ); 173 | this.cropper.setOptions( { show: true } ); 174 | this.cropper.update(); 175 | }, 176 | onSelectStart() { 177 | this.$el.find( '.button-apply-changes, .button-reset' ).prop( 'disabled', true ); 178 | }, 179 | onSelectEnd() { 180 | this.$el.find( '.button-apply-changes, .button-reset' ).prop( 'disabled', false ); 181 | }, 182 | onSelectChange() { 183 | this.$el.find( '.button-apply-changes:disabled, .button-reset:disabled' ).prop( 'disabled', false ); 184 | }, 185 | initCropper() { 186 | const view = this; 187 | const $image = this.$el.find( 'img[id^="image-preview-"]' ); 188 | const $parent = $image.parent(); 189 | const sizeName = this.model.get( 'size' ); 190 | const sizes = this.model.get( 'sizes' ); 191 | const size = sizes[ sizeName ] || null; 192 | 193 | if ( ! size ) { 194 | // Handle error. 195 | return; 196 | } 197 | 198 | const aspectRatio = `${size.width}:${size.height}`; 199 | 200 | // Load imgAreaSelect. 201 | this.cropper = $image.imgAreaSelect( { 202 | parent: $parent, 203 | autoHide: false, 204 | instance: true, 205 | handles: true, 206 | keys: true, 207 | imageWidth: this.model.get( 'width' ), 208 | imageHeight: this.model.get( 'height' ), 209 | minWidth: size.width, 210 | minHeight: size.height, 211 | aspectRatio: aspectRatio, 212 | persistent: true, 213 | onInit( img ) { 214 | // Ensure that the imgAreaSelect wrapper elements are position:absolute. 215 | // (even if we're in a position:fixed modal) 216 | const $img = $( img ); 217 | $img.next().css( 'position', 'absolute' ) 218 | .nextAll( '.imgareaselect-outer' ).css( 'position', 'absolute' ); 219 | 220 | // Account for rounding errors in imgareaselect with jQuery v3 innerHeight sub-pixel values. 221 | $img.width( Math.round( $img.innerWidth() ) ); 222 | $img.height( Math.round( $img.innerHeight() ) ); 223 | 224 | // Set initial crop. 225 | view.reset(); 226 | }, 227 | onSelectStart() { 228 | view.onSelectStart( ...arguments ); 229 | }, 230 | onSelectEnd() { 231 | view.onSelectEnd( ...arguments ); 232 | }, 233 | onSelectChange() { 234 | view.onSelectChange( ...arguments ); 235 | } 236 | } ); 237 | }, 238 | initFocalPoint() { 239 | const width = this.model.get( 'width' ); 240 | const height = this.model.get( 'height' ); 241 | const focalPoint = this.model.get( 'focalPoint' ) || {}; 242 | const $focalPoint = this.$el.find( '.focal-point' ); 243 | 244 | if ( focalPoint.hasOwnProperty( 'x' ) && focalPoint.hasOwnProperty( 'y' ) ) { 245 | $focalPoint.css( { 246 | left: `${ ( 100 / width ) * focalPoint.x }%`, 247 | top: `${ ( 100 / height ) * focalPoint.y }%`, 248 | display: 'block', 249 | } ); 250 | } 251 | }, 252 | onClickPreview( event ) { 253 | const width = this.model.get( 'width' ); 254 | const height = this.model.get( 'height' ); 255 | const x = event.offsetX * ( width / event.currentTarget.offsetWidth ); 256 | const y = event.offsetY * ( height / event.currentTarget.offsetHeight ); 257 | const $focalPoint = this.$el.find( '.focal-point' ); 258 | 259 | $focalPoint.css( { 260 | left: `${ Math.round( ( 100 / width ) * x ) }%`, 261 | top: `${ Math.round( ( 100 / height ) * y ) }%`, 262 | display: 'block', 263 | } ); 264 | 265 | this.setFocalPoint( { x, y } ); 266 | }, 267 | setFocalPoint( coords ) { 268 | ajax.post( 'hm_save_focal_point', { 269 | _ajax_nonce: this.model.get( 'nonces' ).edit, 270 | id: this.model.get( 'id' ), 271 | focalPoint: coords, 272 | } ) 273 | .done( () => { 274 | this.update(); 275 | } ) 276 | .fail( error => console.log( error ) ); 277 | }, 278 | removeFocalPoint( event ) { 279 | this.$el.find( '.focal-point' ).hide(); 280 | event.stopPropagation(); 281 | this.setFocalPoint( false ); 282 | }, 283 | removeCrop() { 284 | ajax.post( 'hm_remove_crop', { 285 | _ajax_nonce: this.model.get( 'nonces' ).edit, 286 | id: this.model.get( 'id' ), 287 | size: this.model.get( 'size' ), 288 | } ) 289 | .done( () => { 290 | // Update & re-render. 291 | this.update(); 292 | } ) 293 | .fail( error => console.log( error ) ); 294 | }, 295 | onEditImage() { 296 | this.$el.find( '.focal-point, .note-focal-point' ).hide(); 297 | this.$el.find( '.imgedit-submit-btn' ).prop( 'disabled', false ); 298 | }, 299 | } ); 300 | 301 | export default ImageEditor; 302 | -------------------------------------------------------------------------------- /inc/cropper/src/views/image-preview.js: -------------------------------------------------------------------------------- 1 | import Media from '@wordpress/media'; 2 | import template from '@wordpress/template'; 3 | 4 | /** 5 | * Image preview. 6 | */ 7 | const ImagePreview = Media.View.extend( { 8 | tagName: 'div', 9 | className: 'hm-thumbnail-editor', 10 | template: template( 'hm-thumbnail-preview' ), 11 | } ); 12 | 13 | export default ImagePreview; 14 | -------------------------------------------------------------------------------- /inc/justified-library/justified-library.css: -------------------------------------------------------------------------------- 1 | .wp-core-ui .attachments-browser .attachment, 2 | .wp-core-ui .media-frame-content[data-columns] .attachment { 3 | width: auto; 4 | max-height: 198px; 5 | } 6 | 7 | .wp-core-ui .attachments-browser .attachment.uploading .thumbnail, 8 | .wp-core-ui .media-frame-content[data-columns] .attachment.uploading .thumbnail { 9 | width: 150px; 10 | height: 150px; 11 | } 12 | 13 | .wp-core-ui .attachments-browser .attachment .attachment-preview::before { 14 | display: none; 15 | } 16 | 17 | .wp-core-ui .attachments-browser .attachment .thumbnail { 18 | position: relative; 19 | max-height: 150px; 20 | transition: width .3s ease-in-out; 21 | } 22 | 23 | .wp-core-ui .attachments-browser .attachment .thumbnail::after { 24 | z-index: 2; 25 | } 26 | 27 | .wp-core-ui .attachments-browser .attachment .thumbnail .centered { 28 | position: relative; 29 | z-index: 1; 30 | -webkit-transform: none; 31 | transform: none; 32 | } 33 | 34 | .wp-core-ui .attachments-browser .attachment .filename { 35 | z-index: 3; 36 | } 37 | 38 | .wp-core-ui .attachments-browser .attachment .thumbnail img { 39 | position: relative; 40 | height: 150px; 41 | width: auto; 42 | display: block; 43 | -webkit-transform: none; 44 | transform: none; 45 | } 46 | 47 | .wp-core-ui .attachments-browser .attachment .thumbnail img.icon { 48 | height: auto; 49 | padding: 60px 50px; 50 | } 51 | 52 | .wp-core-ui .attachments-browser .attachment .button-link { 53 | z-index: 9; 54 | } 55 | -------------------------------------------------------------------------------- /inc/justified-library/namespace.php: -------------------------------------------------------------------------------- 1 |