Step-by-Step Guide to building a custom bulk image watermarker block for Gutenberg using Tailwind CSS isolated elements
Plugin Architecture and Setup
Developing a custom Gutenberg block for bulk image watermarking necessitates a robust plugin architecture. We’ll leverage WordPress’s plugin API and Gutenberg’s block registration mechanisms. The core functionality will reside within a custom PHP class, ensuring maintainability and adherence to WordPress coding standards. For the frontend, we’ll utilize React for the block’s UI and Tailwind CSS for styling, specifically focusing on its utility-first approach to isolate styles and prevent global CSS conflicts.
Our plugin will consist of a main PHP file for registration and asset enqueuing, a JavaScript file for the block’s editor interface, and potentially a PHP file for server-side processing of image watermarking if complex operations are required beyond client-side manipulation (though for simplicity and performance, we’ll aim for client-side processing where feasible).
PHP Block Registration and Asset Enqueuing
The foundation of our Gutenberg block is its PHP registration. This involves defining the block’s attributes, its editor script, and its frontend rendering (if dynamic). We’ll also enqueue necessary JavaScript and CSS files. For Tailwind CSS, we’ll use a build process to generate a minimal CSS file containing only the used classes, which will be enqueued specifically for our block’s context.
`bulk-image-watermarker.php`
<?php
/**
* Plugin Name: Bulk Image Watermarker
* Description: A custom Gutenberg block for bulk watermarking images.
* Version: 1.0.0
* Author: Antigravity
* License: GPL-2.0-or-later
* Text Domain: bulk-image-watermarker
*
* @package BulkImageWatermarker
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
* through the block editor in the corresponding context.
*
* @see https://developer.wordpress.org/reference/functions/register_block_type/
*/
function bulk_image_watermarker_init() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'bulk_image_watermarker_init' );
/**
* Enqueue custom scripts and styles.
*/
function bulk_image_watermarker_enqueue_assets() {
// Enqueue the block editor script and style.
// This is typically handled by register_block_type if block.json is used correctly.
// We'll enqueue our custom Tailwind CSS here.
$asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php');
wp_enqueue_script(
'bulk-image-watermarker-editor-script',
plugins_url( 'build/index.js', __FILE__ ),
$asset_file['dependencies'],
$asset_file['version']
);
// Enqueue the compiled Tailwind CSS for the editor and frontend.
// Ensure this path points to your compiled Tailwind CSS file.
wp_enqueue_style(
'bulk-image-watermarker-tailwind',
plugins_url( 'build/style-index.css', __FILE__ ), // Or 'build/editor.css' if you have separate editor styles
array(),
$asset_file['version']
);
}
add_action( 'enqueue_block_assets', 'bulk_image_watermarker_enqueue_assets' );
`block.json` Configuration
The `block.json` file is crucial for modern Gutenberg block development. It declares the block’s metadata, including its name, title, icon, category, and importantly, the paths to its editor script, editor style, and frontend style. This file also allows us to specify dependencies and attributes.
{
"apiVersion": 2,
"name": "bulk-image-watermarker/block",
"version": "1.0.0",
"title": "Bulk Image Watermarker",
"category": "media",
"icon": "format-gallery",
"description": "Apply watermarks to multiple images in bulk.",
"keywords": [ "watermark", "image", "bulk", "media" ],
"attributes": {
"watermarkText": {
"type": "string",
"default": "© Your Company"
},
"watermarkOpacity": {
"type": "number",
"default": 0.5
},
"watermarkPosition": {
"type": "string",
"default": "bottom-right"
},
"watermarkSize": {
"type": "string",
"default": "medium"
},
"applyToGallery": {
"type": "boolean",
"default": false
}
},
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css",
"supports": {
"html": false
}
}
Frontend and Editor JavaScript (React)
The core of our block’s user interface and logic will be written in JavaScript, using React. We’ll use the `@wordpress/blocks` and `@wordpress/element` packages for block registration and React components. For interacting with the media library, we’ll utilize the `@wordpress/media-upload` component.
`src/index.js` (Block Registration)
/**
* Registers a new block provided a unique name and an object containing
* the metadata and the JavaScript/JSX interface.
*
* All the code about the block itself is in the `edit` function.
*
* @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
*/
import { registerBlockType } from '@wordpress/blocks';
import './style.scss'; // Global styles
import './editor.scss'; // Editor-specific styles
import Edit from './edit';
import save from './save';
/**
* @see ./block.json
*/
registerBlockType( 'bulk-image-watermarker/block', {
/**
* @see ./edit.js
*/
edit: Edit,
/**
* @see ./save.js
*/
save,
} );
`src/edit.js` (Editor Interface)
This file defines the block’s appearance and functionality within the Gutenberg editor. We’ll include controls for setting watermark text, opacity, position, and size. For image selection, we’ll integrate with the WordPress Media Library.
/**
* React component for the block's editor interface.
*/
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
MediaUpload,
MediaUploadCheck,
} from '@wordpress/block-editor';
import { Button, PanelBody, RangeControl, SelectControl, TextControl } from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
import './editor.scss'; // Editor-specific styles
// Assume a utility function for watermarking exists (client-side or server-side call)
// For this example, we'll simulate client-side watermarking logic.
import { applyWatermarkToImage } from './watermarkUtils'; // We'll define this later
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
const {
watermarkText,
watermarkOpacity,
watermarkPosition,
watermarkSize,
applyToGallery,
} = attributes;
const [ selectedImages, setSelectedImages ] = useState( [] );
const [ processedImages, setProcessedImages ] = useState( [] );
const onSelectImages = ( media ) => {
// Filter for images only
const imageFiles = media.filter( item => item.type.startsWith( 'image/' ) );
setSelectedImages( imageFiles );
setProcessedImages( [] ); // Reset processed images when new ones are selected
};
const handleWatermark = async () => {
if ( selectedImages.length === 0 ) {
alert( __( 'Please select images first.', 'bulk-image-watermarker' ) );
return;
}
const results = [];
for ( const image of selectedImages ) {
try {
const watermarkedBlob = await applyWatermarkToImage(
image.url, // URL of the image
watermarkText,
watermarkOpacity,
watermarkPosition,
watermarkSize
);
// Create a temporary URL for preview or download
const watermarkedUrl = URL.createObjectURL( watermarkedBlob );
results.push( { ...image, watermarkedUrl, status: 'success' } );
} catch ( error ) {
console.error( 'Watermarking failed:', error );
results.push( { ...image, status: 'error', errorMessage: error.message } );
}
}
setProcessedImages( results );
};
// Clean up object URLs when component unmounts
useEffect( () => {
return () => {
processedImages.forEach( img => {
if ( img.watermarkedUrl && img.watermarkedUrl.startsWith( 'blob:' ) ) {
URL.revokeObjectURL( img.watermarkedUrl );
}
} );
};
}, [ processedImages ] );
return (
<div { ...blockProps }>
<h3>{ __( 'Bulk Image Watermarker Settings', 'bulk-image-watermarker' ) }</h3>
<PanelBody title={ __( 'Watermark Settings', 'bulk-image-watermarker' ) }>
<TextControl
label={ __( 'Watermark Text', 'bulk-image-watermarker' ) }
value={ watermarkText }
onChange={ ( value ) => setAttributes( { watermarkText: value } ) }
/>
<RangeControl
label={ __( 'Opacity', 'bulk-image-watermarker' ) }
value={ watermarkOpacity }
onChange={ ( value ) => setAttributes( { watermarkOpacity: value } ) }
min={ 0 }
max={ 1 }
step={ 0.1 }
/>
<SelectControl
label={ __( 'Position', 'bulk-image-watermarker' ) }
value={ watermarkPosition }
options={ [
{ label: __( 'Top Left', 'bulk-image-watermarker' ), value: 'top-left' },
{ label: __( 'Top Center', 'bulk-image-watermarker' ), value: 'top-center' },
{ label: __( 'Top Right', 'bulk-image-watermarker' ), value: 'top-right' },
{ label: __( 'Middle Left', 'bulk-image-watermarker' ), value: 'middle-left' },
{ label: __( 'Center', 'bulk-image-watermarker' ), value: 'center' },
{ label: __( 'Middle Right', 'bulk-image-watermarker' ), value: 'middle-right' },
{ label: __( 'Bottom Left', 'bulk-image-watermarker' ), value: 'bottom-left' },
{ label: __( 'Bottom Center', 'bulk-image-watermarker' ), value: 'bottom-center' },
{ label: __( 'Bottom Right', 'bulk-image-watermarker' ), value: 'bottom-right' },
] }
onChange={ ( value ) => setAttributes( { watermarkPosition: value } ) }
/>
<SelectControl
label={ __( 'Size', 'bulk-image-watermarker' ) }
value={ watermarkSize }
options={ [
{ label: __( 'Small', 'bulk-image-watermarker' ), value: 'small' },
{ label: __( 'Medium', 'bulk-image-watermarker' ), value: 'medium' },
{ label: __( 'Large', 'bulk-image-watermarker' ), value: 'large' },
{ label: __( 'Full Width', 'bulk-image-watermarker' ), value: 'full' },
] }
onChange={ ( value ) => setAttributes( { watermarkSize: value } ) }
/>
{/* Add a toggle for applying to galleries if needed */}
</PanelBody>
<MediaUploadCheck>
<MediaUpload
onSelect={ onSelectImages }
allowedTypes={ [ 'image' ] }
multiple={ true }
gallery={ false } // Set to true if you want to select from galleries
value={ selectedImages.map( img => img.id ) } // Pass IDs if available
render={ ( { open } ) => (
<Button
className="components-button is-primary"
onClick={ open }
>
{ __( 'Select Images', 'bulk-image-watermarker' ) }
</Button>
) }
/>
</MediaUploadCheck>
{ selectedImages.length > 0 && (
<div className="selected-images-preview">
<h4>{ __( 'Selected Images:', 'bulk-image-watermarker' ) }</h4>
<ul>
{ selectedImages.map( ( image, index ) => (
<li key={ image.id || index }>
<img src={ image.url } alt={ image.alt || 'Selected Image' } width="50" />
{ image.filename }
</li>
) ) }
</ul>
<Button
className="components-button is-secondary"
onClick={ handleWatermark }
>
{ __( 'Apply Watermark', 'bulk-image-watermarker' ) }
</Button>
</div>
) }
{ processedImages.length > 0 && (
<div className="processed-images-results">
<h4>{ __( 'Watermarking Results:', 'bulk-image-watermarker' ) }</h4>
<ul>
{ processedImages.map( ( image, index ) => (
<li key={ image.id || index } className={ `status-${ image.status }` }>
<img src={ image.watermarkedUrl || image.url } alt={ image.alt || 'Processed Image' } width="50" />
{ image.filename }
{ image.status === 'success' && (
<a href={ image.watermarkedUrl } download={ `watermarked_${ image.filename }` }>
{ __( 'Download', 'bulk-image-watermarker' ) }
</a>
) }
{ image.status === 'error' && (
<span>{ __( 'Error:', 'bulk-image-watermarker' ) } { image.errorMessage }</span>
) }
</li>
) ) }
</ul>
</div>
) }
</div>
);
}
`src/save.js` (Frontend Rendering)
For a block that primarily performs an action (watermarking) and provides results (downloadable images), the `save` function might not need to render complex HTML. It could simply save the block’s attributes. If you intend to display a preview or a confirmation message on the frontend, you would implement that here. For a bulk action block, it’s often better to handle the processing entirely in the editor or via a separate admin interface, rather than on the public-facing site.
/**
* React component for the block's frontend rendering.
* For a bulk action block, this might be minimal.
*/
import { useBlockProps } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const blockProps = useBlockProps.save();
// The actual watermarking happens client-side in the editor.
// This save function primarily saves the settings.
// You might want to save a reference to the processed images or a confirmation message.
return (
<div { ...blockProps }>
<p>{ /* Display saved settings or a placeholder */ }
{ attributes.watermarkText } | Opacity: { attributes.watermarkOpacity } | Position: { attributes.watermarkPosition }
</p>
<p>{ __( 'Watermarking settings saved. Processed images are available in the editor.', 'bulk-image-watermarker' ) }</p>
</div>
);
}
`src/watermarkUtils.js` (Watermarking Logic)
This utility file will contain the core logic for applying the watermark to an image. For client-side processing, we’ll use the HTML5 Canvas API. This avoids server load but has limitations regarding image size and processing time in the browser.
/**
* Utility functions for image watermarking using Canvas API.
*/
// Helper to convert CSS size keywords to pixel values (simplified)
function getSizeInPixels( sizeKeyword, originalWidth ) {
switch ( sizeKeyword ) {
case 'small':
return originalWidth * 0.1;
case 'medium':
return originalWidth * 0.2;
case 'large':
return originalWidth * 0.3;
case 'full':
return originalWidth * 0.9; // 90% of width
default:
return originalWidth * 0.2; // Default to medium
}
}
// Helper to calculate text position
function calculateTextPosition( ctx, text, x, y, position, padding = 10 ) {
const metrics = ctx.measureText( text );
const textWidth = metrics.width;
const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; // Approximate height
let finalX = x;
let finalY = y;
switch ( position ) {
case 'top-left':
finalX = padding;
finalY = padding + textHeight;
break;
case 'top-center':
finalX = ( ctx.canvas.width - textWidth ) / 2;
finalY = padding + textHeight;
break;
case 'top-right':
finalX = ctx.canvas.width - textWidth - padding;
finalY = padding + textHeight;
break;
case 'middle-left':
finalX = padding;
finalY = ( ctx.canvas.height + textHeight ) / 2;
break;
case 'center':
finalX = ( ctx.canvas.width - textWidth ) / 2;
finalY = ( ctx.canvas.height + textHeight ) / 2;
break;
case 'middle-right':
finalX = ctx.canvas.width - textWidth - padding;
finalY = ( ctx.canvas.height + textHeight ) / 2;
break;
case 'bottom-left':
finalX = padding;
finalY = ctx.canvas.height - padding;
break;
case 'bottom-center':
finalX = ( ctx.canvas.width - textWidth ) / 2;
finalY = ctx.canvas.height - padding;
break;
case 'bottom-right':
default: // Default to bottom-right
finalX = ctx.canvas.width - textWidth - padding;
finalY = ctx.canvas.height - padding;
break;
}
return { x: finalX, y: finalY };
}
export const applyWatermarkToImage = ( imageUrl, text, opacity = 0.5, position = 'bottom-right', size = 'medium' ) => {
return new Promise( ( resolve, reject ) => {
const img = new Image();
img.crossOrigin = "Anonymous"; // Necessary for CORS if image is from different origin
img.onload = () => {
const canvas = document.createElement( 'canvas' );
const ctx = canvas.getContext( '2d' );
// Set canvas dimensions to image dimensions
canvas.width = img.width;
canvas.height = img.height;
// Draw the original image onto the canvas
ctx.drawImage( img, 0, 0 );
// Set watermark properties
ctx.globalAlpha = opacity;
ctx.fillStyle = '#FFFFFF'; // Default text color, can be made configurable
ctx.font = `${ getSizeInPixels( size, img.width ) }px Arial`; // Adjust font size based on 'size' attribute
// Calculate position
const { x, y } = calculateTextPosition( ctx, text, 0, 0, position );
// Draw the text watermark
ctx.fillText( text, x, y );
// Convert canvas to a Blob
canvas.toBlob( ( blob ) => {
if ( blob ) {
resolve( blob );
} else {
reject( new Error( 'Failed to create blob from canvas.' ) );
}
}, 'image/png' ); // Or 'image/jpeg' depending on desired output
};
img.onerror = ( error ) => {
reject( new Error( `Failed to load image: ${ imageUrl }. Error: ${ error }` ) );
};
img.src = imageUrl;
} );
};
Styling with Tailwind CSS (Isolated Elements)
To ensure Tailwind CSS styles are isolated to our block and don’t affect the rest of the WordPress site, we’ll configure Tailwind to scan only our block’s source files and then use its purge/content configuration to generate a minimal CSS file. This file will be enqueued as shown in the PHP section.
`tailwind.config.js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}', // Scan React components for classes
'./**/*.php', // Scan PHP files for potential class usage (though less common for dynamic blocks)
// Add any other paths where Tailwind classes might be used
],
theme: {
extend: {},
},
plugins: [],
// Prefixing can further isolate styles if needed, but content scanning is primary
// prefix: 'tw-',
};
`src/editor.scss` and `src/style.scss`
These SCSS files are where you’d import Tailwind directives and potentially add block-specific overrides. The build process (e.g., using Webpack with `tailwindcss/nesting`) will compile these into the final CSS.
/* src/editor.scss */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Add any editor-specific styles here */
.block-editor-block-list__block[data-type="bulk-image-watermarker/block"] {
/* Styles specific to this block in the editor */
border: 1px solid #e2e8f0;
padding: 1rem;
margin-bottom: 1rem;
}
.selected-images-preview,
.processed-images-results {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.selected-images-preview ul,
.processed-images-results ul {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.selected-images-preview li,
.processed-images-results li {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid #ddd;
padding: 0.5rem;
border-radius: 0.25rem;
background-color: #f9fafb;
}
.selected-images-preview img,
.processed-images-results img {
max-width: 50px;
height: auto;
margin-bottom: 0.25rem;
}
.processed-images-results li.status-error {
border-color: #f87171;
background-color: #fef2f2;
}
.processed-images-results li.status-error span {
font-size: 0.75rem;
color: #dc2626;
}
.processed-images-results li a {
font-size: 0.75rem;
text-decoration: none;
color: #3b82f6;
margin-top: 0.25rem;
}
/* src/style.scss */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Add any frontend-specific styles here */
.bulk-image-watermarker-block {
/* Example: Add a subtle border on the frontend */
border: 1px dashed #ccc;
padding: 1rem;
}
Build Process and Deployment
To compile the React components, transpile modern JavaScript, and process Tailwind CSS, a build tool is essential. WordPress’s `@wordpress/scripts` package provides a convenient setup for this. Ensure you have Node.js and npm/yarn installed.
`package.json`
{
"name": "bulk-image-watermarker",
"version": "1.0.0",
"description": "Bulk Image Watermarker Gutenberg Block",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"package": "wp-scripts start --zip"
},
"author": "Antigravity",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@wordpress/scripts": "^26.0.0",
"tailwindcss": "^3.3.0"
},
"dependencies": {
"@wordpress/block-editor": "^12.0.0",
"@wordpress/blocks": "^12.0.0",
"@wordpress/components": "^25.0.0",
"@wordpress/element": "^5.0.0",
"@wordpress/i18n": "^5.0.0"
}
}
Run the following commands in your plugin’s root directory:
npm install npm run build
This will create the `build/` directory containing the compiled JavaScript (`index.js`), CSS (`index.css`, `style-index.css`), and asset files (`index.asset.php`) needed by WordPress. The `index.asset.php` file is crucial as it tells WordPress the dependencies and version of your script, allowing for proper cache busting.
Advanced Considerations and Enhancements
For enterprise-level solutions, consider the following:
- Server-Side Processing: For large images or complex watermarking (e.g., image watermarks, batch resizing), offload processing to the server using PHP’s GD or Imagick libraries. This would involve an AJAX endpoint to handle image uploads and processing.
- Error Handling and Feedback: Implement more sophisticated error reporting to the user, especially for server-side operations.
- Image Format Support: Ensure compatibility with various image formats (JPEG, PNG, WebP). Client-side canvas might have limitations with certain formats or transparency.
- Performance Optimization: For client-side processing, consider debouncing input changes and optimizing canvas operations. For server-side, implement queuing systems for batch jobs.
- Internationalization (i18n): Use WordPress’s `__()` and `_x()` functions for all user-facing strings, as demonstrated.
- Security: Sanitize all user inputs and validate file types if implementing server-side processing.
- Accessibility: Ensure ARIA attributes and keyboard navigation are properly implemented for all controls.
- Integration with Media Library: Instead of just uploading, allow users to select existing images from the Media Library and potentially update them in place (with user confirmation).
By following this structured approach, you can build a powerful and maintainable custom Gutenberg block for bulk image watermarking, leveraging modern web technologies and WordPress best practices.