diff --git a/image-filters.php b/image-filters.php index 0a26a96..2755f7d 100644 --- a/image-filters.php +++ b/image-filters.php @@ -26,16 +26,39 @@ define( 'IMAGE_FILTERS_URL', plugin_dir_url( __FILE__ ) ); /** * Bootstrap the plugin. * - * Block registration is wired in a follow-up task. This function exists so the - * activation hook can be added without needing a global-side-effect pattern. + * Registers the Filtered Image block from the build directory. The registered + * block type is static (its markup is produced by the JS save function). * * @return void */ function image_filters_bootstrap(): void { - // Block registration will be added in Task 5. + if ( ! function_exists( 'register_block_type' ) ) { + return; + } + + register_block_type( + IMAGE_FILTERS_DIR . 'build', + array( + 'render_callback' => 'image_filters_render_block', + ) + ); } add_action( 'plugins_loaded', 'image_filters_bootstrap' ); +/** + * Render callback for the Filtered Image block. + * + * Static blocks render their own markup via save.js. The render_callback + * is a placeholder for future server-side logic (e.g. dynamic alt text). + * + * @param array $attributes Block attributes. + * @param string $content Saved block content. + * @return string + */ +function image_filters_render_block( array $attributes, string $content = '' ): string { + return $content; +} + /** * Load plugin textdomain for translations. * diff --git a/src/block.json b/src/block.json new file mode 100644 index 0000000..3bd56bf --- /dev/null +++ b/src/block.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "ksolo/image-filter", + "version": "0.1.0", + "title": "Filtered Image", + "category": "media", + "icon": "image-filter", + "description": "An image with an Instagram-style filter applied.", + "keywords": [ "filter", "instagram", "image" ], + "textdomain": "image-filters", + "supports": { + "align": [ "left", "center", "right", "wide", "full" ], + "anchor": true, + "html": false, + "spacing": { "margin": true } + }, + "attributes": { + "filter": { + "type": "string", + "default": "normal" + }, + "intensity": { + "type": "number", + "default": 100 + }, + "imageId": { + "type": "number", + "default": 0 + }, + "imageUrl": { + "type": "string", + "default": "", + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "imageAlt": { + "type": "string", + "default": "", + "source": "attribute", + "selector": "img", + "attribute": "alt" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "linkUrl": { + "type": "string", + "default": "" + }, + "caption": { + "type": "string", + "default": "", + "source": "html", + "selector": "figcaption" + } + }, + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css", + "style": "file:./style-index.css" +} diff --git a/src/edit.js b/src/edit.js new file mode 100644 index 0000000..ce386ae --- /dev/null +++ b/src/edit.js @@ -0,0 +1,80 @@ +/** + * Editor component for the Filtered Image block. + * + * The MediaPlaceholder is the same UX as the core Image block. After an + * image is selected, the block renders the image with the filter class + * and intensity CSS custom property. The Inspector panel is mounted + * separately. + * + * @package ImageFilters + */ + +import { __ } from '@wordpress/i18n'; +import { useBlockProps, MediaPlaceholder, BlockControls } from '@wordpress/block-editor'; +import { Button, ToolbarGroup, ToolbarItem } from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +export default function Edit( { attributes, setAttributes } ) { + const { filter, intensity, imageId, imageUrl, imageAlt, width, height } = attributes; + const blockProps = useBlockProps( { + className: `wp-block-ksolo-image-filter has-filter-${ filter }`, + style: { '--filter-intensity': String( intensity / 100 ) }, + } ); + + const [ isEditing, setIsEditing ] = useState( ! imageUrl ); + + if ( isEditing || ! imageUrl ) { + return ( +
+ { + setAttributes( { + imageId: media.id, + imageUrl: media.url, + imageAlt: media.alt || '', + width: media.width, + height: media.height, + } ); + setIsEditing( false ); + } } + allowedTypes={ [ 'image' ] } + multiple={ false } + labels={ { + title: __( 'Filtered Image', 'image-filters' ), + instructions: __( + 'Upload or select an image to apply a filter.', + 'image-filters' + ), + } } + /> +
+ ); + } + + return ( +
+ + + + { () => ( + + ) } + + + + { +
+ ); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8f792c4 --- /dev/null +++ b/src/index.js @@ -0,0 +1,77 @@ +/** + * Block registration entry point. + * + * @package ImageFilters + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { addFilter } from '@wordpress/hooks'; + +import './styles/style.scss'; +import './styles/editor.scss'; + +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import { PRESETS, DEFAULT_PRESET, isValidPresetSlug } from './presets'; +import { FilterPanel } from './inspector'; + +registerBlockType( metadata.name, { + ...metadata, + edit, + save, +} ); + +/** + * Add the filter panel to the Image block's inspector when the active + * block is the Filtered Image block. We use the editor.BlockEdit filter + * so we don't have to re-implement the entire MediaPlaceholder UX. + * + * @param {Function} BlockEdit + * @return {Function} + */ +function withFilterPanel( BlockEdit ) { + return ( props ) => { + if ( props.name !== 'ksolo/image-filter' ) { + return ; + } + return ( + <> + + + + ); + }; +} +addFilter( 'editor.BlockEdit', 'image-filters/with-filter-panel', withFilterPanel ); + +/** + * Normalise the filter attribute on save. If the saved slug is unknown + * (e.g. the post was edited by hand), fall back to DEFAULT_PRESET so the + * block still renders without errors. + */ +function ensureValidFilter( settings, name ) { + if ( name !== 'ksolo/image-filter' ) { + return settings; + } + const originalSave = settings.save; + settings.save = ( props ) => { + if ( ! isValidPresetSlug( props.attributes.filter ) ) { + props.attributes.filter = DEFAULT_PRESET; + } + return originalSave( props ); + }; + return settings; +} +addFilter( + 'blocks.registerBlockType', + 'image-filters/ensure-valid-filter', + ensureValidFilter +); + +// Re-export PRESETS so the consuming downstream code (e.g. a future +// design-tool integration) can grab them from this module. +export { PRESETS }; diff --git a/src/save.js b/src/save.js new file mode 100644 index 0000000..6618946 --- /dev/null +++ b/src/save.js @@ -0,0 +1,37 @@ +/** + * Static markup for the Filtered Image block. + * + * Renders a normal with the filter class and an inline CSS custom + * property for intensity. The CSS in style.scss renders the filter; the + * saved HTML is fully static and no client-side JS is needed to display + * it. + * + * @package ImageFilters + */ + +import { useBlockProps } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { filter, intensity, imageUrl, imageAlt, width, height, caption } = attributes; + const blockProps = useBlockProps.save( { + className: `wp-block-ksolo-image-filter has-filter-${ filter }`, + style: { '--filter-intensity': String( intensity / 100 ) }, + } ); + + if ( ! imageUrl ) { + return null; + } + + return ( +
+ { + { caption &&
{ caption }
} +
+ ); +}