/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { InspectorControls, BlockControls, useBlockProps, getSpacingPresetCssVar, isValueSpacingPreset, store as blockEditorStore, __experimentalSpacingSizesControl as SpacingSizesControl, } from '@wordpress/block-editor'; import { ResizableBox, RangeControl, HorizontalRule, ToolbarGroup, ToolbarButton, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, } from '@wordpress/components'; import { Link, Stack, SwitchControl } from '@wordpress/ui'; import { useState } from '@wordpress/element'; import { Icon, settings, mobile, tablet, desktop } from '@wordpress/icons'; import { addQueryArgs } from '@wordpress/url'; import { useViewportMatch } from '@wordpress/compose'; import type { BlockEditProps } from '@wordpress/blocks'; /** * Internal dependencies */ import { responsive } from './icons'; import { store } from './store'; import type { BlockAttributes } from './types'; import { MIN_SPACER_HEIGHT, MAX_SPACER_HEIGHT, DEFAULT_SPACER_HEIGHT, DEFAULT_SPACER_HEIGHT_UNIT, } from './constants'; type DeviceSlug = 'lg' | 'md' | 'sm'; interface SpacerControl { label: string; icon: React.JSX.Element; slug: string; value: string | undefined; syncKey?: number; onChange: ( value: string | undefined ) => void; isNegative?: boolean; onNegativeChange?: ( value: boolean ) => void; hasValue: () => boolean; onDeselect: () => void; } // Renders the height input for a single device. function HeightControl( { label, icon, value = '', syncKey, onChange, onMouseOver, onMouseOut, }: { label: string; icon: React.JSX.Element; value?: string; syncKey?: number; onChange: ( value: string | undefined ) => void; onMouseOver: () => void; onMouseOut: () => void; } ) { const disableCustomSpacingSizes = useSelect( ( select ) => select( blockEditorStore ).getSettings().disableCustomSpacingSizes, [] ); const [ parsedQuantity, parsedUnit ] = parseQuantityAndUnitFromRawValue( value ); // With custom values disabled the core control renders the preset UI alone, so // custom values can no longer be edited. The core Spacer block behaves the same // way, but restore a numeric input here for backward compatibility. if ( disableCustomSpacingSizes ) { return (
} min={ MIN_SPACER_HEIGHT } max={ MAX_SPACER_HEIGHT } value={ parsedQuantity } onChange={ ( next ) => onChange( next === undefined ? undefined : `${ next }${ parsedUnit || DEFAULT_SPACER_HEIGHT_UNIT }` ) } />
); } // An empty string renders as a blank custom input rather than as a preset // slider parked on "None". const computedValue = isValueSpacingPreset( value ) ? value : [ parsedQuantity, parsedUnit ].join( '' ); return (
onChange( all || undefined ) } label={ label } sides={ [ 'all' ] } showSideInLabel={ false } onMouseOver={ onMouseOver } onMouseOut={ onMouseOut } />
); } interface SpacerDevice { label: string; slug: DeviceSlug; icon: React.JSX.Element; isNegative: boolean; height: string; onResizeStart: () => void; onResize: () => void; onResizeStop: ( event: unknown, direction: unknown, elt: HTMLElement ) => void; isResizing: boolean; } export default function Edit( { attributes, isSelected, setAttributes, toggleSelection, }: BlockEditProps< BlockAttributes > & { // Injected by the block editor at runtime; not part of `BlockEditProps`. toggleSelection?: ( isSelectionEnabled: boolean ) => void; } ) { const { heightLg, heightMd, heightSm, isNegativeLg, isNegativeMd, isNegativeSm } = attributes; const heightAll = heightLg === heightMd && heightMd === heightSm ? heightLg : undefined; const [ activeDevice, setActiveDevice ] = useState< string | undefined >( undefined ); const [ resizingDevice, setResizingDevice ] = useState< DeviceSlug | undefined >( undefined ); // A control picks its preset or custom view when it mounts and never switches // back on its own, so remount the counterpart when the other side writes a // height. const [ deviceSyncKey, setDeviceSyncKey ] = useState( 0 ); const [ allSyncKey, setAllSyncKey ] = useState( 0 ); const isResponsive = useSelect( ( select ) => select( store ).getIsResponsive(), [] ); const { setIsResponsive } = useDispatch( store ); const isMobile = useViewportMatch( 'medium', '<' ); const isEnableMd = fsbConf.breakpoint.md !== fsbConf.breakpoint.sm; const breakpoints = [ fsbConf.breakpoint.sm, ...( isEnableMd ? [ fsbConf.breakpoint.md ] : [] ) ]; const { showBlock: isShowBlock, defaultValue } = fsbConf; const defaultLgValue = defaultValue.lg + defaultValue.lg_unit; const defaultMdValue = defaultValue.md + defaultValue.md_unit; const defaultSmValue = defaultValue.sm + defaultValue.sm_unit; // Matches the fallback used by `save` when a height is empty. const fallbackHeight = `${ DEFAULT_SPACER_HEIGHT }${ DEFAULT_SPACER_HEIGHT_UNIT }`; const settingUrl = addQueryArgs( 'options-general.php', { page: 'flexible-spacer-block-option', } ); const blockProps = useBlockProps( { className: clsx( 'fsb-flexible-spacer', { 'is-show-block': !! isShowBlock, 'is-responsive': !! isResponsive, } ), } ); // Writing a device height leaves "All heights" on the view it mounted with, // so remount it. Its own handler bumps the key in the other direction. const setDeviceHeights = ( heights: Partial< BlockAttributes > ) => { setAttributes( heights ); setAllSyncKey( ( key ) => key + 1 ); }; const resetAll = () => { setDeviceHeights( { heightLg: defaultLgValue, heightMd: isEnableMd ? defaultMdValue : defaultLgValue, heightSm: defaultSmValue, isNegativeLg: false, isNegativeMd: false, isNegativeSm: false, } ); }; const SPACER_CONTROLS: SpacerControl[] = [ { label: __( 'All heights', 'flexible-spacer-block' ), icon: settings, slug: 'all', value: heightAll, syncKey: allSyncKey, onChange: ( value ) => { setAttributes( { heightLg: value, heightMd: value, heightSm: value } ); setDeviceSyncKey( ( key ) => key + 1 ); }, hasValue: () => heightLg !== defaultLgValue || heightMd !== defaultMdValue || heightSm !== defaultSmValue || isNegativeLg || isNegativeMd || isNegativeSm, onDeselect: resetAll, }, { label: __( 'Desktop height', 'flexible-spacer-block' ), icon: desktop, slug: 'lg', value: heightLg, syncKey: deviceSyncKey, onChange: ( value ) => { const newHeights = { heightLg: value, ...( ! isEnableMd && { heightMd: value } ) }; setDeviceHeights( newHeights ); }, isNegative: isNegativeLg, onNegativeChange: ( value ) => { const newAttributes = { isNegativeLg: value, ...( ! isEnableMd && { isNegativeMd: value } ), }; setAttributes( newAttributes ); }, hasValue: () => heightLg !== defaultLgValue || isNegativeLg, onDeselect: () => setDeviceHeights( { heightLg: defaultLgValue, isNegativeLg: false } ), }, { label: __( 'Tablet height', 'flexible-spacer-block' ), icon: tablet, slug: 'md', value: heightMd, syncKey: deviceSyncKey, onChange: ( value ) => setDeviceHeights( { heightMd: value } ), isNegative: isNegativeMd, onNegativeChange: ( value ) => setAttributes( { isNegativeMd: value } ), hasValue: () => heightMd !== defaultMdValue || isNegativeMd, onDeselect: () => setDeviceHeights( { heightMd: defaultMdValue, isNegativeMd: false } ), }, { label: __( 'Mobile height', 'flexible-spacer-block' ), icon: mobile, slug: 'sm', value: heightSm, syncKey: deviceSyncKey, onChange: ( value ) => setDeviceHeights( { heightSm: value } ), isNegative: isNegativeSm, onNegativeChange: ( value ) => setAttributes( { isNegativeSm: value } ), hasValue: () => heightSm !== defaultSmValue || isNegativeSm, onDeselect: () => setDeviceHeights( { heightSm: defaultSmValue, isNegativeSm: false } ), }, ]; const SPACER_DEVICES: SpacerDevice[] = [ { label: __( 'Mobile', 'flexible-spacer-block' ), slug: 'sm' as const, icon: mobile, isNegative: isNegativeSm, height: heightSm || fallbackHeight, enabled: true, }, { label: __( 'Tablet', 'flexible-spacer-block' ), slug: 'md' as const, icon: tablet, isNegative: isNegativeMd, height: heightMd || fallbackHeight, enabled: isEnableMd, }, { label: __( 'Desktop', 'flexible-spacer-block' ), slug: 'lg' as const, icon: desktop, isNegative: isNegativeLg, height: heightLg || fallbackHeight, enabled: true, }, ] .filter( ( device ) => device.enabled ) .map( ( { enabled, ...device } ) => { // Resizing writes the height through the same handler as the height control. const control = SPACER_CONTROLS.find( ( { slug } ) => slug === device.slug ); return { ...device, isResizing: resizingDevice === device.slug, onResizeStart: () => toggleSelection?.( false ), onResize: () => setResizingDevice( device.slug ), onResizeStop: ( _event: unknown, _direction: unknown, elt: HTMLElement ) => { control?.onChange( `${ elt.clientHeight }px` ); setResizingDevice( undefined ); }, }; } ); const dropdownMenuProps = ! isMobile ? { popoverProps: { placement: 'left-start' as const, offset: 259, }, } : {}; return ( <> setIsResponsive( ! isResponsive ) } /> { SPACER_CONTROLS.map( ( control ) => ( setActiveDevice( control.slug ) } onMouseOut={ () => setActiveDevice( undefined ) } /> { control.onNegativeChange && ( ) } ) ) } { __( 'Plugin Setting', 'flexible-spacer-block' ) }
{ breakpoints.map( ( breakpoint ) => ( { sprintf( /* translators: %d: Breakpoint width in pixels. */ __( '≤ %dpx <', 'flexible-spacer-block' ), breakpoint ) } ) ) } { SPACER_DEVICES.map( ( device ) => (
{ device.label }
) ) }
); }