/**
* 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,
ToggleControl,
HorizontalRule,
ToolbarGroup,
ToolbarButton,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
} from '@wordpress/components';
import { Link, Stack } from '@wordpress/ui';
import { useEffect, useState } from '@wordpress/element';
import { View } from '@wordpress/primitives';
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 HeightValue = string | number | undefined;
interface SpacerControl {
label: string;
icon: 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: 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 }`
)
}
__next40pxDefaultSize
/>
);
}
// 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: string;
icon: 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 [ isResizingLg, setIsResizingLg ] = useState( false );
const [ isResizingMd, setIsResizingMd ] = useState( false );
const [ isResizingSm, setIsResizingSm ] = useState( false );
// 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 = parseInt( fsbConf.breakpoint.md ) !== parseInt( fsbConf.breakpoint.sm );
const isShowBlock = fsbConf.showBlock;
const defaultValue = fsbConf.defaultValue;
// Apply default values from the settings page when inserting a block.
useEffect( () => {
if (
heightLg === `${ DEFAULT_SPACER_HEIGHT }${ DEFAULT_SPACER_HEIGHT_UNIT }` &&
heightMd === `${ DEFAULT_SPACER_HEIGHT }${ DEFAULT_SPACER_HEIGHT_UNIT }` &&
heightSm === `${ DEFAULT_SPACER_HEIGHT }${ DEFAULT_SPACER_HEIGHT_UNIT }`
) {
setAttributes( {
heightLg: defaultValue.lg + defaultValue.lg_unit,
heightMd: defaultValue.md + defaultValue.md_unit,
heightSm: defaultValue.sm + defaultValue.sm_unit,
} );
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
const defaultLgValue = defaultValue.lg + defaultValue.lg_unit;
const defaultMdValue = defaultValue.md + defaultValue.md_unit;
const defaultSmValue = defaultValue.sm + defaultValue.sm_unit;
const settingUrl = addQueryArgs( 'options-general.php', {
page: 'flexible-spacer-block-option',
} );
const blockProps = useBlockProps( {
className: clsx( 'fsb-flexible-spacer', {
'fsb-flexible-spacer--is-show-block': !! isShowBlock,
'fsb-flexible-spacer--is-responsive': !! isResponsive,
} ),
} );
function getUpdatedHeight(
currentValue: HeightValue,
newValue: HeightValue
): string | undefined {
if ( ! newValue ) {
return undefined;
}
const [ newParsedQuantity, newParsedUnit ] = parseQuantityAndUnitFromRawValue( newValue );
const [ , currentParsedUnit ] = parseQuantityAndUnitFromRawValue( currentValue );
const newUnit = newParsedUnit || currentParsedUnit || 'px';
return newParsedQuantity + newUnit;
}
// 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 onChangeHeightLg = ( currentValue: HeightValue, newValue: HeightValue ) => {
const updatedHeight = getUpdatedHeight( currentValue, newValue );
const newHeights = {
heightLg: updatedHeight,
...( ! isEnableMd && { heightMd: updatedHeight } ),
};
setDeviceHeights( newHeights );
};
const onChangeHeightMd = ( currentValue: HeightValue, newValue: HeightValue ) => {
setDeviceHeights( { heightMd: getUpdatedHeight( currentValue, newValue ) } );
};
const onChangeHeightSm = ( currentValue: HeightValue, newValue: HeightValue ) => {
setDeviceHeights( { heightSm: getUpdatedHeight( currentValue, newValue ) } );
};
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',
icon: mobile,
isNegative: isNegativeSm,
height: heightSm || defaultValue.sm,
isResizing: isResizingSm,
setIsResizing: setIsResizingSm,
onChangeHeight: onChangeHeightSm,
enabled: true,
},
{
label: __( 'Tablet', 'flexible-spacer-block' ),
slug: 'md',
icon: tablet,
isNegative: isNegativeMd,
height: heightMd || defaultValue.md,
isResizing: isResizingMd,
setIsResizing: setIsResizingMd,
onChangeHeight: onChangeHeightMd,
enabled: isEnableMd,
},
{
label: __( 'Desktop', 'flexible-spacer-block' ),
slug: 'lg',
icon: desktop,
isNegative: isNegativeLg,
height: heightLg || defaultValue.lg,
isResizing: isResizingLg,
setIsResizing: setIsResizingLg,
onChangeHeight: onChangeHeightLg,
enabled: true,
},
]
.filter( ( device ) => device.enabled )
.map( ( { enabled, setIsResizing, onChangeHeight, ...device } ) => ( {
...device,
onResizeStart: () => toggleSelection?.( false ),
onResize: () => setIsResizing( true ),
onResizeStop: ( _event: unknown, _direction: unknown, elt: HTMLElement ) => {
onChangeHeight( undefined, `${ elt.clientHeight }px` );
setIsResizing( false );
},
} ) );
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' ) }
{ sprintf(
/* translators: %d: Breakpoint width in pixels. */
__( '≤ %dpx <', 'flexible-spacer-block' ),
Number( fsbConf.breakpoint.sm )
) }
{ isEnableMd && (
{ sprintf(
/* translators: %d: Breakpoint width in pixels. */
__( '≤ %dpx <', 'flexible-spacer-block' ),
Number( fsbConf.breakpoint.md )
) }
) }
{ SPACER_DEVICES.map( ( device, index ) => (
{ device.label }
{ /* `children` is required by the type, but is not actually needed here, so render a dummy element. */ }
{ /* TODO: Remove this once https://github.com/WordPress/gutenberg/pull/79370 is merged. */ }
<>>
) ) }
>
);
}