diff --git a/assets/before-after/frontblocks-before-after-frontend.js b/assets/before-after/frontblocks-before-after-frontend.js new file mode 100644 index 0000000..d160e7b --- /dev/null +++ b/assets/before-after/frontblocks-before-after-frontend.js @@ -0,0 +1,85 @@ +( function () { + 'use strict'; + + function initBeforeAfterBlock( block ) { + var initialPosition = parseFloat( block.dataset.initialPosition ) || 50; + var beforeEl = block.querySelector( '.frbl-before-after__before' ); + var handle = block.querySelector( '.frbl-before-after__handle' ); + + if ( ! beforeEl || ! handle ) { + return; + } + + var isDragging = false; + + function clamp( value, min, max ) { + return Math.max( min, Math.min( max, value ) ); + } + + function setPosition( pos ) { + pos = clamp( pos, 0, 100 ); + beforeEl.style.clipPath = 'inset(0 ' + ( 100 - pos ) + '% 0 0)'; + handle.style.left = pos + '%'; + handle.setAttribute( 'aria-valuenow', Math.round( pos ) ); + } + + function getPositionFromEvent( e ) { + var rect = block.getBoundingClientRect(); + var clientX = e.touches && e.touches.length ? e.touches[ 0 ].clientX : e.clientX; + return ( ( clientX - rect.left ) / rect.width ) * 100; + } + + setPosition( initialPosition ); + + handle.addEventListener( 'mousedown', function ( e ) { + isDragging = true; + e.preventDefault(); + } ); + + handle.addEventListener( 'touchstart', function () { + isDragging = true; + }, { passive: true } ); + + document.addEventListener( 'mousemove', function ( e ) { + if ( ! isDragging ) { return; } + setPosition( getPositionFromEvent( e ) ); + } ); + + document.addEventListener( 'touchmove', function ( e ) { + if ( ! isDragging ) { return; } + setPosition( getPositionFromEvent( e ) ); + }, { passive: true } ); + + document.addEventListener( 'mouseup', function () { + isDragging = false; + } ); + + document.addEventListener( 'touchend', function () { + isDragging = false; + } ); + + handle.setAttribute( 'tabindex', '0' ); + + handle.addEventListener( 'keydown', function ( e ) { + var current = parseFloat( handle.style.left ) || initialPosition; + if ( e.key === 'ArrowLeft' ) { + setPosition( current - 1 ); + e.preventDefault(); + } else if ( e.key === 'ArrowRight' ) { + setPosition( current + 1 ); + e.preventDefault(); + } + } ); + } + + function init() { + var blocks = document.querySelectorAll( '.frbl-before-after:not(.frbl-before-after--editor)' ); + blocks.forEach( initBeforeAfterBlock ); + } + + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); + } else { + init(); + } +} )(); diff --git a/assets/before-after/frontblocks-before-after-option.jsx b/assets/before-after/frontblocks-before-after-option.jsx new file mode 100644 index 0000000..dc07ef3 --- /dev/null +++ b/assets/before-after/frontblocks-before-after-option.jsx @@ -0,0 +1,275 @@ +const { registerBlockType } = wp.blocks; +const { Fragment } = wp.element; +const { InspectorControls, MediaUpload, MediaUploadCheck, useBlockProps } = wp.blockEditor; +const { PanelBody, RangeControl, Button, Placeholder, TextControl, ToggleControl } = wp.components; +const { __ } = wp.i18n; + +/** + * Edit component for Before After block. + */ +function BeforeAfterEdit( props ) { + const { attributes, setAttributes } = props; + const { + beforeImageId, + beforeImageUrl, + afterImageId, + afterImageUrl, + beforeLabel, + afterLabel, + initialPosition, + blockHeight, + fixedHeight, + } = attributes; + + const blockProps = useBlockProps(); + + const hasImages = beforeImageUrl && afterImageUrl; + + return ( + + + + + + setAttributes( { + beforeImageId: media.id, + beforeImageUrl: media.url, + } ) + } + allowedTypes={ [ 'image' ] } + value={ beforeImageId } + render={ ( { open } ) => ( +
+ { beforeImageUrl && ( + + ) } + + { beforeImageUrl && ( + + ) } +
+ ) } + /> +
+ setAttributes( { beforeLabel: value } ) } + style={ { marginTop: '12px' } } + /> +
+ + + + + setAttributes( { + afterImageId: media.id, + afterImageUrl: media.url, + } ) + } + allowedTypes={ [ 'image' ] } + value={ afterImageId } + render={ ( { open } ) => ( +
+ { afterImageUrl && ( + + ) } + + { afterImageUrl && ( + + ) } +
+ ) } + /> +
+ setAttributes( { afterLabel: value } ) } + style={ { marginTop: '12px' } } + /> +
+ + + setAttributes( { initialPosition: value } ) } + min={ 0 } + max={ 100 } + /> + setAttributes( { fixedHeight: value } ) } + /> + { fixedHeight && ( + setAttributes( { blockHeight: value } ) } + min={ 100 } + max={ 1000 } + step={ 10 } + /> + ) } + +
+ +
+ { ! hasImages ? ( + + ) : ( +
+
+ + { afterLabel && ( + + { afterLabel } + + ) } +
+
+ + { beforeLabel && ( + + { beforeLabel } + + ) } +
+
+ + + + + +
+
+ ) } +
+
+ ); +} + +// Register the block. +registerBlockType( 'frontblocks/before-after', { + title: __( 'Before After', 'frontblocks' ), + description: __( 'Compare two images with a draggable before/after slider.', 'frontblocks' ), + category: 'media', + icon: 'image-flip-horizontal', + keywords: [ + __( 'before', 'frontblocks' ), + __( 'after', 'frontblocks' ), + __( 'comparison', 'frontblocks' ), + __( 'slider', 'frontblocks' ), + ], + attributes: { + beforeImageId: { + type: 'integer', + }, + beforeImageUrl: { + type: 'string', + default: '', + }, + afterImageId: { + type: 'integer', + }, + afterImageUrl: { + type: 'string', + default: '', + }, + beforeLabel: { + type: 'string', + default: 'Before', + }, + afterLabel: { + type: 'string', + default: 'After', + }, + initialPosition: { + type: 'number', + default: 50, + }, + fixedHeight: { + type: 'boolean', + default: false, + }, + blockHeight: { + type: 'number', + default: 400, + }, + }, + edit: BeforeAfterEdit, + save: function () { + return null; // Dynamic block, rendered server-side. + }, +} ); diff --git a/assets/before-after/frontblocks-before-after.css b/assets/before-after/frontblocks-before-after.css new file mode 100644 index 0000000..e4c0e4d --- /dev/null +++ b/assets/before-after/frontblocks-before-after.css @@ -0,0 +1,150 @@ +/* ============================================================================= + Before After Block — FrontBlocks + ============================================================================= */ + +.frbl-before-after { + position: relative; + overflow: hidden; + cursor: col-resize; + -webkit-user-select: none; + user-select: none; + touch-action: pan-y; + display: block; + width: 100%; +} + +/* After image — defines the block height */ +.frbl-before-after__after { + position: relative; + width: 100%; + display: block; +} + +.frbl-before-after__after img { + display: block; + width: 100%; + height: auto; + pointer-events: none; +} + +/* Fixed height mode: images scale to fit without cropping */ +.frbl-before-after--fixed-height .frbl-before-after__after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.frbl-before-after--fixed-height .frbl-before-after__after img, +.frbl-before-after--fixed-height .frbl-before-after__before img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* Before image — absolutely positioned on top, clipped from the right */ +.frbl-before-after__before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + clip-path: inset( 0 50% 0 0 ); +} + +.frbl-before-after__before img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +/* Labels */ +.frbl-before-after__label:empty { + display: none; +} + +.frbl-before-after__label { + position: absolute; + top: 16px; + padding: 4px 12px; + background: rgba( 0, 0, 0, 0.55 ); + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + border-radius: 3px; + letter-spacing: 0.02em; + z-index: 5; + pointer-events: none; +} + +.frbl-before-after__label--before { + left: 16px; +} + +.frbl-before-after__label--after { + right: 16px; +} + +/* Handle */ +.frbl-before-after__handle { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + transform: translateX( -50% ); + width: 44px; + display: flex; + flex-direction: column; + align-items: center; + z-index: 10; + cursor: col-resize; +} + +.frbl-before-after__handle:focus { + outline: none; +} + +.frbl-before-after__handle:focus-visible .frbl-before-after__handle-thumb { + outline: 2px solid #007cba; + outline-offset: 2px; +} + +.frbl-before-after__handle-line { + flex: 1; + width: 2px; + background: #fff; + box-shadow: 0 0 4px rgba( 0, 0, 0, 0.4 ); +} + +.frbl-before-after__handle-thumb { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 50%; + background: #fff; + box-shadow: 0 2px 10px rgba( 0, 0, 0, 0.35 ); + display: flex; + align-items: center; + justify-content: center; + color: #555; +} + +.frbl-before-after__handle-thumb svg { + display: block; +} + +/* Editor — disable interaction */ +.frbl-before-after--editor { + cursor: default; +} + +.frbl-before-after--editor .frbl-before-after__handle { + cursor: default; + pointer-events: none; +} diff --git a/assets/before-after/frontblocks-before-after.js b/assets/before-after/frontblocks-before-after.js new file mode 100644 index 0000000..e37db4f --- /dev/null +++ b/assets/before-after/frontblocks-before-after.js @@ -0,0 +1,277 @@ +"use strict"; + +var registerBlockType = wp.blocks.registerBlockType; +var Fragment = wp.element.Fragment; +var _wp$blockEditor = wp.blockEditor, + InspectorControls = _wp$blockEditor.InspectorControls, + MediaUpload = _wp$blockEditor.MediaUpload, + MediaUploadCheck = _wp$blockEditor.MediaUploadCheck, + useBlockProps = _wp$blockEditor.useBlockProps; +var _wp$components = wp.components, + PanelBody = _wp$components.PanelBody, + RangeControl = _wp$components.RangeControl, + Button = _wp$components.Button, + Placeholder = _wp$components.Placeholder, + TextControl = _wp$components.TextControl, + ToggleControl = _wp$components.ToggleControl; +var __ = wp.i18n.__; + +/** + * Edit component for Before After block. + */ +function BeforeAfterEdit(props) { + var attributes = props.attributes, + setAttributes = props.setAttributes; + var beforeImageId = attributes.beforeImageId, + beforeImageUrl = attributes.beforeImageUrl, + afterImageId = attributes.afterImageId, + afterImageUrl = attributes.afterImageUrl, + beforeLabel = attributes.beforeLabel, + afterLabel = attributes.afterLabel, + initialPosition = attributes.initialPosition, + blockHeight = attributes.blockHeight, + fixedHeight = attributes.fixedHeight; + var blockProps = useBlockProps(); + var hasImages = beforeImageUrl && afterImageUrl; + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(InspectorControls, null, /*#__PURE__*/React.createElement(PanelBody, { + title: __('Before Image', 'frontblocks'), + initialOpen: true + }, /*#__PURE__*/React.createElement(MediaUploadCheck, null, /*#__PURE__*/React.createElement(MediaUpload, { + onSelect: function onSelect(media) { + return setAttributes({ + beforeImageId: media.id, + beforeImageUrl: media.url + }); + }, + allowedTypes: ['image'], + value: beforeImageId, + render: function render(_ref) { + var open = _ref.open; + return /*#__PURE__*/React.createElement("div", null, beforeImageUrl && /*#__PURE__*/React.createElement("img", { + src: beforeImageUrl, + alt: "", + style: { + width: '100%', + marginBottom: '8px', + borderRadius: '2px' + } + }), /*#__PURE__*/React.createElement(Button, { + onClick: open, + variant: beforeImageUrl ? 'secondary' : 'primary', + style: { + width: '100%' + } + }, beforeImageUrl ? __('Replace Before Image', 'frontblocks') : __('Select Before Image', 'frontblocks')), beforeImageUrl && /*#__PURE__*/React.createElement(Button, { + onClick: function onClick() { + return setAttributes({ + beforeImageId: undefined, + beforeImageUrl: '' + }); + }, + variant: "link", + isDestructive: true, + style: { + marginTop: '4px', + display: 'block' + } + }, __('Remove', 'frontblocks'))); + } + })), /*#__PURE__*/React.createElement(TextControl, { + label: __('Before Label', 'frontblocks'), + value: beforeLabel, + onChange: function onChange(value) { + return setAttributes({ + beforeLabel: value + }); + }, + style: { + marginTop: '12px' + } + })), /*#__PURE__*/React.createElement(PanelBody, { + title: __('After Image', 'frontblocks'), + initialOpen: true + }, /*#__PURE__*/React.createElement(MediaUploadCheck, null, /*#__PURE__*/React.createElement(MediaUpload, { + onSelect: function onSelect(media) { + return setAttributes({ + afterImageId: media.id, + afterImageUrl: media.url + }); + }, + allowedTypes: ['image'], + value: afterImageId, + render: function render(_ref2) { + var open = _ref2.open; + return /*#__PURE__*/React.createElement("div", null, afterImageUrl && /*#__PURE__*/React.createElement("img", { + src: afterImageUrl, + alt: "", + style: { + width: '100%', + marginBottom: '8px', + borderRadius: '2px' + } + }), /*#__PURE__*/React.createElement(Button, { + onClick: open, + variant: afterImageUrl ? 'secondary' : 'primary', + style: { + width: '100%' + } + }, afterImageUrl ? __('Replace After Image', 'frontblocks') : __('Select After Image', 'frontblocks')), afterImageUrl && /*#__PURE__*/React.createElement(Button, { + onClick: function onClick() { + return setAttributes({ + afterImageId: undefined, + afterImageUrl: '' + }); + }, + variant: "link", + isDestructive: true, + style: { + marginTop: '4px', + display: 'block' + } + }, __('Remove', 'frontblocks'))); + } + })), /*#__PURE__*/React.createElement(TextControl, { + label: __('After Label', 'frontblocks'), + value: afterLabel, + onChange: function onChange(value) { + return setAttributes({ + afterLabel: value + }); + }, + style: { + marginTop: '12px' + } + })), /*#__PURE__*/React.createElement(PanelBody, { + title: __('Slider Settings', 'frontblocks'), + initialOpen: false + }, /*#__PURE__*/React.createElement(RangeControl, { + label: __('Initial Handle Position (%)', 'frontblocks'), + value: initialPosition, + onChange: function onChange(value) { + return setAttributes({ + initialPosition: value + }); + }, + min: 0, + max: 100 + }), /*#__PURE__*/React.createElement(ToggleControl, { + label: __('Fixed Height', 'frontblocks'), + checked: fixedHeight, + onChange: function onChange(value) { + return setAttributes({ + fixedHeight: value + }); + } + }), fixedHeight && /*#__PURE__*/React.createElement(RangeControl, { + label: __('Height (px)', 'frontblocks'), + value: blockHeight, + onChange: function onChange(value) { + return setAttributes({ + blockHeight: value + }); + }, + min: 100, + max: 1000, + step: 10 + }))), /*#__PURE__*/React.createElement("div", blockProps, !hasImages ? /*#__PURE__*/React.createElement(Placeholder, { + icon: "image-flip-horizontal", + label: __('Before / After', 'frontblocks'), + instructions: __('Select a "before" image and an "after" image from the sidebar.', 'frontblocks') + }) : /*#__PURE__*/React.createElement("div", { + className: 'frbl-before-after frbl-before-after--editor' + (fixedHeight ? ' frbl-before-after--fixed-height' : ''), + "data-initial-position": initialPosition, + style: fixedHeight && blockHeight ? { + height: blockHeight + 'px' + } : {} + }, /*#__PURE__*/React.createElement("div", { + className: "frbl-before-after__after" + }, /*#__PURE__*/React.createElement("img", { + src: afterImageUrl, + alt: "" + }), afterLabel && /*#__PURE__*/React.createElement("span", { + className: "frbl-before-after__label frbl-before-after__label--after" + }, afterLabel)), /*#__PURE__*/React.createElement("div", { + className: "frbl-before-after__before", + style: { + clipPath: "inset(0 ".concat(100 - initialPosition, "% 0 0)") + } + }, /*#__PURE__*/React.createElement("img", { + src: beforeImageUrl, + alt: "" + }), beforeLabel && /*#__PURE__*/React.createElement("span", { + className: "frbl-before-after__label frbl-before-after__label--before" + }, beforeLabel)), /*#__PURE__*/React.createElement("div", { + className: "frbl-before-after__handle", + style: { + left: "".concat(initialPosition, "%") + } + }, /*#__PURE__*/React.createElement("span", { + className: "frbl-before-after__handle-line" + }), /*#__PURE__*/React.createElement("span", { + className: "frbl-before-after__handle-thumb" + }, /*#__PURE__*/React.createElement("svg", { + viewBox: "0 0 20 20", + width: "18", + height: "18", + fill: "currentColor", + "aria-hidden": "true" + }, /*#__PURE__*/React.createElement("path", { + d: "M7 4l-4 6 4 6M13 4l4 6-4 6", + stroke: "currentColor", + strokeWidth: "2", + fill: "none", + strokeLinecap: "round", + strokeLinejoin: "round" + }))), /*#__PURE__*/React.createElement("span", { + className: "frbl-before-after__handle-line" + }))))); +} + +// Register the block. +registerBlockType('frontblocks/before-after', { + title: __('Before After', 'frontblocks'), + description: __('Compare two images with a draggable before/after slider.', 'frontblocks'), + category: 'media', + icon: 'image-flip-horizontal', + keywords: [__('before', 'frontblocks'), __('after', 'frontblocks'), __('comparison', 'frontblocks'), __('slider', 'frontblocks')], + attributes: { + beforeImageId: { + type: 'integer' + }, + beforeImageUrl: { + type: 'string', + default: '' + }, + afterImageId: { + type: 'integer' + }, + afterImageUrl: { + type: 'string', + default: '' + }, + beforeLabel: { + type: 'string', + default: 'Before' + }, + afterLabel: { + type: 'string', + default: 'After' + }, + initialPosition: { + type: 'number', + default: 50 + }, + fixedHeight: { + type: 'boolean', + default: false + }, + blockHeight: { + type: 'number', + default: 400 + } + }, + edit: BeforeAfterEdit, + save: function save() { + return null; // Dynamic block, rendered server-side. + } +}); diff --git a/includes/Frontend/BeforeAfter.php b/includes/Frontend/BeforeAfter.php new file mode 100644 index 0000000..6a7212b --- /dev/null +++ b/includes/Frontend/BeforeAfter.php @@ -0,0 +1,213 @@ + 'frontblocks-before-after-option', + 'render_callback' => array( $this, 'render_before_after_block' ), + 'attributes' => array( + 'beforeImageId' => array( + 'type' => 'integer', + ), + 'beforeImageUrl' => array( + 'type' => 'string', + 'default' => '', + ), + 'afterImageId' => array( + 'type' => 'integer', + ), + 'afterImageUrl' => array( + 'type' => 'string', + 'default' => '', + ), + 'beforeLabel' => array( + 'type' => 'string', + 'default' => 'Before', + ), + 'afterLabel' => array( + 'type' => 'string', + 'default' => 'After', + ), + 'initialPosition' => array( + 'type' => 'number', + 'default' => 50, + ), + 'fixedHeight' => array( + 'type' => 'boolean', + 'default' => false, + ), + 'blockHeight' => array( + 'type' => 'number', + 'default' => 400, + ), + ), + ); + + if ( ! WP_Block_Type_Registry::get_instance()->is_registered( 'frontblocks/before-after' ) ) { + register_block_type( 'frontblocks/before-after', $args ); + } + } + + /** + * Render the Before After block on the frontend. + * + * @param array $attributes Block attributes. + * @return string HTML output. + */ + public function render_before_after_block( $attributes ) { + $before_url = isset( $attributes['beforeImageUrl'] ) ? $attributes['beforeImageUrl'] : ''; + $after_url = isset( $attributes['afterImageUrl'] ) ? $attributes['afterImageUrl'] : ''; + + if ( empty( $before_url ) || empty( $after_url ) ) { + return ''; + } + + $initial_position = isset( $attributes['initialPosition'] ) ? (int) $attributes['initialPosition'] : 50; + $before_label = isset( $attributes['beforeLabel'] ) ? $attributes['beforeLabel'] : __( 'Before', 'frontblocks' ); + $after_label = isset( $attributes['afterLabel'] ) ? $attributes['afterLabel'] : __( 'After', 'frontblocks' ); + + $fixed_height = ! empty( $attributes['fixedHeight'] ); + $block_height = isset( $attributes['blockHeight'] ) ? (int) $attributes['blockHeight'] : 400; + + $wrapper_class = 'frbl-before-after'; + if ( $fixed_height ) { + $wrapper_class .= ' frbl-before-after--fixed-height'; + } + if ( ! empty( $attributes['className'] ) ) { + $wrapper_class .= ' ' . esc_attr( $attributes['className'] ); + } + + $wrapper_style = $fixed_height && $block_height ? ' style="height:' . esc_attr( $block_height ) . 'px"' : ''; + + ob_start(); + ?> +
+ > +
+ + + + + + +
+
+ + + + + + +
+
+ + + + + +
+
+