diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..b0ef602 --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,106 @@ +# Cimo Developer Integration + +Cimo can optimize media in the browser before your plugin or theme uploads it. + +## Automatic Selector Interception + +Use PHP selector filters when your markup uses normal file inputs or drop zones +and you want Cimo to intercept them automatically before your existing upload +handler runs. + +Register selector filters before calling `cimo_enqueue_assets()`. +`cimo_enqueue_assets()` localizes the filtered selector lists while enqueueing, +so filters added afterward will not apply to the current page load. + +```php +add_filter( 'cimo/select_files/allowed_locations', function ( $locations ) { + $locations[] = '.my-plugin-uploader'; + return $locations; +} ); + +add_filter( 'cimo/drop_zone/allowed_locations', function ( $locations ) { + $locations[] = '.my-plugin-dropzone'; + return $locations; +} ); +``` + +For file inputs, register a selector for a wrapper around +``. + +```html +
+ +
+``` + +For drops, register a selector for the drop target. + +## Enqueue Cimo + +Admin screens and the block editor already enqueue Cimo. For frontend upload +forms or custom screens where Cimo is not already loaded, call +`cimo_enqueue_assets()` after registering any selector filters needed for that +page. + +```php +add_action( 'wp_enqueue_scripts', function () { + if ( is_page( 'my-upload-form' ) && function_exists( 'cimo_enqueue_assets' ) ) { + cimo_enqueue_assets(); + } +} ); +``` + +## Optimize Files Directly + +Use `window.cimo.optimizeFiles()` when your code already controls the upload +process and can replace the selected files before uploading. + +```js +async function handleFiles( files ) { + const results = await window.cimo.optimizeFiles( files, { showProgress: true } ) + const filesToUpload = results.map( result => result.file ) + + // Continue with your plugin/theme upload flow. + uploadFiles( filesToUpload ) +} +``` + +The API accepts a single `File`, a `FileList`, or an array of `File` objects. +It returns: + +```js +[ + { + file: File, + metadata: Object || null, + }, +] +``` + +`showProgress` defaults to `true`. Set it to `false` if your UI already shows +upload or optimization progress. + +If Cimo optimization is disabled or a file type is unsupported, the original +file is returned with `metadata: null`. + +## Which Approach To Use + +Use `window.cimo.optimizeFiles()` when you can explicitly await optimization +before calling your uploader. + +Use PHP selector interception when your existing UI already reacts to file input +or drop events and you want Cimo to transparently replace files before that flow +continues. + +## Free And Premium Behavior + +The same JavaScript API is used in free and premium. If Cimo Premium is loaded, +premium converters are applied automatically through Cimo's normal converter +pipeline. There is no separate premium entry point. + +Cimo optimization is pre-upload only. It does not optimize by attachment ID, +bulk replace existing files, or run server-side Imagick/GD compression. + +On guest frontend uploads, files can still be optimized in the browser. Metadata +saving may be skipped when the visitor is not logged in or cannot access the +Cimo metadata endpoint. diff --git a/cimo.php b/cimo.php index 9e6f403..da58cb0 100644 --- a/cimo.php +++ b/cimo.php @@ -18,10 +18,22 @@ } defined( 'CIMO_FILE' ) || define( 'CIMO_FILE', __FILE__ ); -defined( 'CIMO_BUILD' ) || define( 'CIMO_BUILD', 'free' ); +defined( 'CIMO_BUILD' ) || define( 'CIMO_BUILD', 'premium' ); defined( 'CIMO_SETTINGS_SLUG' ) || define( 'CIMO_SETTINGS_SLUG', 'cimo-settings' ); require_once __DIR__ . '/src/admin/class-script-loader.php'; + +if ( ! function_exists( 'cimo_enqueue_assets' ) ) { + /** + * Public helper for integrators to enqueue Cimo on custom upload screens. + */ + function cimo_enqueue_assets() { + if ( class_exists( 'Cimo_Script_Loader' ) ) { + Cimo_Script_Loader::enqueue_cimo_assets(); + } + } +} + require_once __DIR__ . '/src/admin/class-meta-box.php'; require_once __DIR__ . '/src/admin/class-metadata.php'; require_once __DIR__ . '/src/admin/class-admin-notices.php'; @@ -44,4 +56,4 @@ function cimo_activate() { if ( file_exists( plugin_dir_path( __FILE__ ) . 'pro__premium_only/index.php' ) ) { require_once( plugin_dir_path( __FILE__ ) . 'pro__premium_only/index.php' ); } -} \ No newline at end of file +} diff --git a/src/admin/class-script-loader.php b/src/admin/class-script-loader.php index 76e80ed..9418fed 100644 --- a/src/admin/class-script-loader.php +++ b/src/admin/class-script-loader.php @@ -48,6 +48,67 @@ public function maybe_enqueue_for_bricks_builder() { } } + /** + * Get selectors where Cimo should intercept file input selections. + * + * @return array + */ + public static function get_select_files_allowed_locations() { + $locations = [ + '.components-form-file-upload', // Allow uploads to the image block. + '.media-frame', // Allow uploads from the Media Manager. + '.media-upload-form', // Allow uploads from the admin Media > Add Media File. + '.moxie-shim', // Allow uploads from the admin Media > Library grid view. + ]; + + // Public integration point for plugins/themes that want Cimo to auto-intercept + // file inputs inside their own upload UI. + return self::sanitize_allowed_locations( + apply_filters( 'cimo/select_files/allowed_locations', $locations ) + ); + } + + /** + * Get selectors where Cimo should intercept file drops. + * + * @return array + */ + public static function get_drop_zone_allowed_locations() { + $locations = [ + '.media-frame-uploader', // Allowed to drop in the Media Manager. + '.media-upload-form', // Allowed to drop in the admin Media > Add Media File. + '.editor-post-featured-image', // Allowed to drop in the featured image drop zone. + '.editor-styles-wrapper', // Allowed to drop in the block editor when adding new image blocks. + '.uploader-window', // Allowed to drop in the admin Media > Library grid view. + '.uploader-editor', // Allowed to drop in the WooCommerce description editor. + '.block-library-utils__media-control', // Allowed to drop in the block editor image block media control. + ]; + + // Public integration point for plugins/themes that want Cimo to auto-intercept + // dropped files inside their own upload UI. + return self::sanitize_allowed_locations( + apply_filters( 'cimo/drop_zone/allowed_locations', $locations ) + ); + } + + /** + * Keep localized selector settings as a clean list of strings. + * + * @param mixed $locations Selectors from defaults and filters. + * @return array + */ + private static function sanitize_allowed_locations( $locations ) { + if ( ! is_array( $locations ) ) { + return []; + } + + $locations = array_filter( $locations, 'is_string' ); + $locations = array_map( 'trim', $locations ); + $locations = array_filter( $locations ); + + return array_values( array_unique( $locations ) ); + } + public static function enqueue_cimo_assets() { // If cimo-script is already enqueued, don't enqueue again. if ( wp_script_is( 'cimo-script', 'enqueued' ) ) { @@ -112,6 +173,9 @@ public static function enqueue_cimo_assets() { 'svgOptimizationEnabled' => isset( $settings['svg_optimization_enabled'] ) ? (int) $settings['svg_optimization_enabled'] : 1, 'stealthModeEnabled' => isset( $settings['stealth_mode_enabled'] ) ? (int) $settings['stealth_mode_enabled'] : 0, 'wpScalingThreshold' => $threshold, + // Public PHP selector filters are localized for the browser-side interceptors. + 'selectFilesAllowedLocations' => self::get_select_files_allowed_locations(), + 'dropZoneAllowedLocations' => self::get_drop_zone_allowed_locations(), ] ); diff --git a/src/admin/js/index.js b/src/admin/js/index.js index fb75ac4..05d3c4c 100644 --- a/src/admin/js/index.js +++ b/src/admin/js/index.js @@ -1,3 +1,4 @@ +import './public-api' import './media-manager/drop-zone' import './media-manager/select-files' import './media-manager/sidebar-info' diff --git a/src/admin/js/media-manager/allowed-locations.js b/src/admin/js/media-manager/allowed-locations.js new file mode 100644 index 0000000..d0c839f --- /dev/null +++ b/src/admin/js/media-manager/allowed-locations.js @@ -0,0 +1,56 @@ +import { applyFilters } from '@wordpress/hooks' + +const normalizeLocations = locations => { + if ( ! Array.isArray( locations ) ) { + return [] + } + + return locations.filter( location => typeof location === 'string' && location.length > 0 ) +} + +/** + * Get current select file selectors from PHP settings and internal JS filters. + * + * @return {Array} Selectors where file input changes should be intercepted. + */ +export const getSelectFilesAllowedLocations = () => { + const locations = normalizeLocations( window.cimoSettings?.selectFilesAllowedLocations ) + + return normalizeLocations( applyFilters( 'cimo.selectFiles.allowedLocations', locations ) ) +} + +/** + * Get current drop zone selectors from PHP settings and internal JS filters. + * + * @return {Array} Selectors where file drops should be intercepted. + */ +export const getDropZoneAllowedLocations = () => { + const locations = normalizeLocations( window.cimoSettings?.dropZoneAllowedLocations ) + + return normalizeLocations( applyFilters( 'cimo.dropZone.allowedLocations', locations ) ) +} + +/** + * Find the closest ancestor matching one of Cimo's allowed upload locations. + * + * @param {Element} element Element where the upload event started. + * @param {Array} locations CSS selectors to test. + * @return {Element|null} Matching element, if one exists. + */ +export const closestAllowedLocation = ( element, locations ) => { + for ( const location of locations ) { + try { + // Invalid selectors should not break uploads; ignore them and keep checking. + const matchedElement = element.closest( location ) + + if ( matchedElement ) { + return matchedElement + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.warn( `[Cimo] Ignoring invalid selector: ${ location }`, error ) + } + } + + return null +} diff --git a/src/admin/js/media-manager/drop-zone.js b/src/admin/js/media-manager/drop-zone.js index deee381..46af5b5 100644 --- a/src/admin/js/media-manager/drop-zone.js +++ b/src/admin/js/media-manager/drop-zone.js @@ -8,27 +8,14 @@ import { domReady } from '~cimo/shared/dom-ready' import { getFileConverter, requiresFileConversion } from '~cimo/shared/converters' import { watchForEditorIframe } from '~cimo/shared/util' -import { saveMetadata } from '~cimo/shared/metadata-saver' -import { cacheConverterNotice } from '~cimo/shared/upload-notice-cache' -import { ProgressModal } from './progress-modal' -import { applyFilters } from '@wordpress/hooks' +import { optimizeFileConverters } from '../optimize-files' +import { closestAllowedLocation, getDropZoneAllowedLocations } from './allowed-locations' /** * Intercept editor media uploads and convert images to WebP on the client * before uploading to WordPress. This affects the block editor only. */ -// Allowed locations to be able to select files. -const ALLOWED_LOCATIONS = applyFilters( 'cimo.dropZone.allowedLocations', [ - '.media-frame-uploader', // Allowed to drop in the Media Manager - '.media-upload-form', // Allowed to drop in the admin Media > Add Media File - '.editor-post-featured-image', // Allowed to drop in the featured image drop zone - '.editor-styles-wrapper', // Allowed to drop in the block editor when adding new image blocks - '.uploader-window', // Allowed to drop in the admin Media > Library grid view - '.uploader-editor', // Allowed to drop in the WooCommerce description editor - '.block-library-utils__media-control', // Allowed to drop in the block editor image block media control -] ) - // Add event listener to the Media Manager's drop zone function addDropZoneListenerToMediaManager( targetDocument ) { if ( ! targetDocument ) { @@ -59,18 +46,8 @@ function addDropZoneListenerToMediaManager( targetDocument ) { return } - // TODO: We also want to filter out the target so we can set when this - // is triggered. We might break other funcitonality that we don't have - // the conversion to happen. - // Find the matched element based on the locations - let matchedElement - for ( const location of ALLOWED_LOCATIONS ) { - matchedElement = event.target.closest( location ) - if ( matchedElement ) { - break - } - } + let matchedElement = closestAllowedLocation( event.target, getDropZoneAllowedLocations() ) // Allowed a fallback to drop in the Media Manager matchedElement = matchedElement || event.target.closest( '.supports-drag-drop' ) @@ -99,39 +76,12 @@ function addDropZoneListenerToMediaManager( targetDocument ) { uploaderWindow.style.display = 'none' } - // Cancel the conversion if the user closes the progress modal. - const onCancel = () => { - // This is enough to cancel the entire process since the promises below will finish resolving. - fileConverters.forEach( converter => converter.cancel() ) - } - - // Show the progress modal - const progressModal = new ProgressModal( fileConverters, onCancel ) - progressModal.open() - // Process and optimize each media file here, // e.g. converting to webp, resizing, compressing, etc. - const optimizedResults = await Promise.all( - fileConverters.map( async converter => { - try { - const result = await converter.optimize() - cacheConverterNotice( result ) - if ( result.error ) { - // eslint-disable-next-line no-console - console.warn( result.error ) - } - return result - } catch ( error ) { - // eslint-disable-next-line no-console - console.warn( error ) - return { file: converter.file, metadata: null } - } - } ) - ) + const optimizedResults = await optimizeFileConverters( fileConverters ) // Extract files and metadata from results const optimizedFiles = optimizedResults.map( result => result.file ) - const conversionMetadata = optimizedResults.map( result => result.metadata ) // Create a DataTransfer to hold the optimized file const dataTransfer = new DataTransfer() @@ -139,9 +89,6 @@ function addDropZoneListenerToMediaManager( targetDocument ) { dataTransfer.items.add( file ) } ) - // Save the metadata to the server. - await saveMetadata( conversionMetadata ) - // Find the correct target to dispatch the event to let target = event.target.closest( '.components-drop-zone, [data-is-drop-zone="true"]' ) || event.target // This specifically handles the WooCommerce/classic description editor @@ -218,9 +165,6 @@ function addDropZoneListenerToMediaManager( targetDocument ) { target.dispatchEvent( dropEvent ) } } - - // Close when optimization finishes, including when we fall back to the original file after an error. - progressModal.close() } // Add our custom drop listener diff --git a/src/admin/js/media-manager/select-files.js b/src/admin/js/media-manager/select-files.js index cf5db4e..b3a5d45 100644 --- a/src/admin/js/media-manager/select-files.js +++ b/src/admin/js/media-manager/select-files.js @@ -8,18 +8,8 @@ import { domReady } from '~cimo/shared/dom-ready' import { getFileConverter, requiresFileConversion } from '~cimo/shared/converters' import { watchForEditorIframe } from '~cimo/shared/util' -import { saveMetadata } from '~cimo/shared/metadata-saver' -import { cacheConverterNotice } from '~cimo/shared/upload-notice-cache' -import { ProgressModal } from './progress-modal' -import { applyFilters } from '@wordpress/hooks' - -// Allowed locations to be able to select files. -const ALLOWED_LOCATIONS = applyFilters( 'cimo.selectFiles.allowedLocations', [ - '.components-form-file-upload', // Allow uploads to the image block - '.media-frame', // Allow uploads from the Media Manager - '.media-upload-form', // Allow uploads from the admin Media > Add Media File - '.moxie-shim', // Allow uploads from the admin Media > Library grid view -] ) +import { optimizeFileConverters } from '../optimize-files' +import { closestAllowedLocation, getSelectFilesAllowedLocations } from './allowed-locations' // Add event listener to the Media Manager's drop zone function addSelectFilesListenerToFileUploads( targetDocument ) { @@ -56,12 +46,8 @@ function addSelectFilesListenerToFileUploads( targetDocument ) { return } - // TODO: We need filter this so that we will only override this on file - // selects that we want to, like the media manager picker or the image - // block uploader. // Allow these locations to be able to select files. - const isAllowed = ALLOWED_LOCATIONS.some( selector => event.target.closest( selector ) ) - if ( ! isAllowed ) { + if ( ! closestAllowedLocation( event.target, getSelectFilesAllowedLocations() ) ) { return } @@ -77,39 +63,12 @@ function addSelectFilesListenerToFileUploads( targetDocument ) { event.stopPropagation() event.stopImmediatePropagation() - // Cancel the conversion if the user closes the progress modal. - const onCancel = () => { - // This is enough to cancel the entire process since the promises below will finish resolving. - fileConverters.forEach( converter => converter.cancel() ) - } - - // Show the progress modal - const progressModal = new ProgressModal( fileConverters, onCancel ) - progressModal.open() - // Process and optimize each media file here, // e.g. converting to webp, resizing, compressing, etc. - const optimizedResults = await Promise.all( - fileConverters.map( async converter => { - try { - const result = await converter.optimize() - cacheConverterNotice( result ) - if ( result.error ) { - // eslint-disable-next-line no-console - console.warn( result.error ) - } - return result - } catch ( error ) { - // eslint-disable-next-line no-console - console.warn( error ) - return { file: converter.file, metadata: null } - } - } ) - ) + const optimizedResults = await optimizeFileConverters( fileConverters ) // Extract files from results const optimizedFiles = optimizedResults.map( result => result.file ) - const conversionMetadata = optimizedResults.map( result => result.metadata ) // Create a DataTransfer to hold the optimized file const dataTransfer = new DataTransfer() @@ -117,9 +76,6 @@ function addSelectFilesListenerToFileUploads( targetDocument ) { dataTransfer.items.add( file ) } ) - // Save the metadata to the server. - await saveMetadata( conversionMetadata ) - // Assign the files to the input element event.target.files = dataTransfer.files @@ -128,9 +84,6 @@ function addSelectFilesListenerToFileUploads( targetDocument ) { // Mark this event so we know conversion is already done changeEvent.__cimo_converted = true // eslint-disable-line camelcase event.target.dispatchEvent( changeEvent ) - - // Close when optimization finishes, including when we fall back to the original file after an error. - progressModal.close() } if ( ! targetDocument.body.__cimo_selectfiles_listener_attached ) { diff --git a/src/admin/js/optimize-files.js b/src/admin/js/optimize-files.js new file mode 100644 index 0000000..aabd013 --- /dev/null +++ b/src/admin/js/optimize-files.js @@ -0,0 +1,128 @@ +import { getFileConverter, requiresFileConversion } from '~cimo/shared/converters' +import { saveMetadata } from '~cimo/shared/metadata-saver' +import { cacheConverterNotice } from '~cimo/shared/upload-notice-cache' +import { ProgressModal } from './media-manager/progress-modal' + +/** + * Normalize the public API input shape. Integrators may pass a single File, + * FileList, DataTransfer file list, or plain array of File objects. + * + * @param {File|FileList|Array} files Files to optimize. + * @return {Array} Normalized file array. + */ +const normalizeFiles = files => { + if ( ! files ) { + return [] + } + + if ( typeof files.length === 'number' ) { + return Array.from( files ) + } + + return [ files ] +} + +/** + * Optimize an existing converter list and save Cimo metadata before the + * caller's upload flow continues. + * + * Interceptors use this after they have already checked whether conversion is + * required. The public API usually calls optimizeFiles() instead. + * + * @param {Array} fileConverters Converter instances. + * @param {Object} options Options for the optimization run. + * @param {boolean} [options.showProgress=true] Whether to show Cimo's progress modal. + * @return {Promise>} Optimized files and metadata. + */ +export const optimizeFileConverters = async ( fileConverters, options = {} ) => { + const converters = Array.isArray( fileConverters ) ? fileConverters : [] + + if ( window.cimoSettings?.disableOptimization ) { + return converters.map( converter => ( { + file: converter.file, + metadata: null, + } ) ) + } + + const showProgress = options.showProgress !== false + + // Cancel the conversion if the user closes the progress modal. + const onCancel = () => { + // This is enough to cancel the entire process since the promises below will finish resolving. + converters.forEach( converter => converter.cancel() ) + } + + // Show the progress modal. + const progressModal = showProgress + ? new ProgressModal( converters, onCancel ) + : null + + progressModal?.open() + + try { + // Process and optimize each media file here, + // e.g. converting to webp, resizing, compressing, etc. + const optimizedResults = await Promise.all( + converters.map( async converter => { + try { + const result = await converter.optimize() + cacheConverterNotice( result ) + if ( result.error ) { + // eslint-disable-next-line no-console + console.warn( result.error ) + } + return result + } catch ( error ) { + // eslint-disable-next-line no-console + console.warn( error ) + return { file: converter.file, metadata: null } + } + } ) + ) + + // Save the metadata to the server. + await saveMetadata( optimizedResults.map( result => result.metadata ?? null ) ) + + return optimizedResults.map( result => ( { + file: result.file, + metadata: result.metadata ?? null, + } ) ) + } finally { + // Close when optimization finishes, including when we fall back to the original file after an error. + progressModal?.close() + } +} + +/** + * Public optimization entry point exposed as window.cimo.optimizeFiles(). + * + * Cimo only handles pre-upload optimization here. The integrator remains + * responsible for uploading results.map( result => result.file ) through their + * normal upload flow. + * + * @param {File|FileList|Array} files Files to optimize before upload. + * @param {Object} options Options for the optimization run. + * @param {boolean} [options.showProgress=true] Whether to show Cimo's progress modal. + * @return {Promise>} Files for the caller to upload. + */ +export const optimizeFiles = async ( files, options = {} ) => { + if ( window.cimoSettings?.disableOptimization ) { + return normalizeFiles( files ).map( file => ( { + file, + metadata: null, + } ) ) + } + + // Use Cimo's shared converter resolver so free and premium converters follow + // the same path as Media Library uploads. + const fileConverters = normalizeFiles( files ).map( file => getFileConverter( file ) ) + + if ( ! requiresFileConversion( fileConverters ) ) { + return fileConverters.map( converter => ( { + file: converter.file, + metadata: null, + } ) ) + } + + return optimizeFileConverters( fileConverters, options ) +} diff --git a/src/admin/js/public-api.js b/src/admin/js/public-api.js new file mode 100644 index 0000000..3d520fe --- /dev/null +++ b/src/admin/js/public-api.js @@ -0,0 +1,7 @@ +import { optimizeFiles } from './optimize-files' + +window.cimo = window.cimo || {} + +// Stable public API for plugins/themes that need pre-upload optimization. +// Returns { file, metadata } results. +window.cimo.optimizeFiles = optimizeFiles