├── .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 |
4 | Smart Media
5 | Enhanced media library features for WordPress.
6 | |
7 |
8 | |
9 |
10 |
11 |
12 | A Human Made project. Maintained by @roborourke.
13 | |
14 |
15 |
16 | |
17 |
18 |
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 |