Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
@@ -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
`<input type="file">`.

```html
<div class="my-plugin-uploader">
<input type="file" accept="image/*">
</div>
```

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();
}
} );
Comment thread
Arukuen marked this conversation as resolved.
```

## 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 )
}
```
Comment thread
Arukuen marked this conversation as resolved.

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.
16 changes: 14 additions & 2 deletions cimo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' );
}
}
}
64 changes: 64 additions & 0 deletions src/admin/class-script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ) {
Expand Down Expand Up @@ -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(),
]
);

Expand Down
1 change: 1 addition & 0 deletions src/admin/js/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './public-api'
import './media-manager/drop-zone'
import './media-manager/select-files'
import './media-manager/sidebar-info'
56 changes: 56 additions & 0 deletions src/admin/js/media-manager/allowed-locations.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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<string>} 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<string>} 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
}
64 changes: 4 additions & 60 deletions src/admin/js/media-manager/drop-zone.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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' )
Expand Down Expand Up @@ -99,49 +76,19 @@ 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()
optimizedFiles.forEach( file => {
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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading