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