import * as React from 'react'
import {__, _x, sprintf} from '@wordpress/i18n'
import {
useBlockProps,
InspectorControls,
InspectorAdvancedControls,
BlockControls,
MediaPlaceholder,
MediaReplaceFlow,
// @ts-expect-error
} from '@wordpress/block-editor'
import {
useEffect,
useMemo,
useState,
} from '@wordpress/element'
import {
Button,
ToolbarButton,
BaseControl,
TextControl,
TextareaControl,
ToggleControl,
RangeControl,
Notice,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
} from '@wordpress/components'
import {
select,
useSelect,
} from '@wordpress/data'
import {
store as coreDataStore,
useEntityProp,
type Attachment,
} from '@wordpress/core-data'
import {
createBlock,
// @ts-expect-error
} from '@wordpress/blocks'
import {
__unstableStripHTML as stripHTML,
} from '@wordpress/dom'
import {
Player as LottiePlayer,
Controls as LottiePlayerControls,
} from '@lottiefiles/react-lottie-player'
import {
ButtonGroup,
skaSvgIcon,
WithTooltip,
} from '@ska/components'
import {
SkaPanelBody,
} from '@ska/plugin'
import {
IS_DEBUG,
sanitizeSVG,
usePostMeta,
} from '@ska/utils'
import {
IconPicker,
PlaceholderImageControl,
SVGEditModal,
ImageSizeControl,
} from '../../components'
import {
placeholderImageUrl,
} from '../../data'
import InlineImageButton from './InlineImageButton'
// @ts-ignore
import metadata from './block.json'
import {
useLinkWrapper,
LinkAttributes,
LinkControls,
} from '../../supports/link'
import {
useAttributes,
AttributesAttributes,
} from '../../supports'
import {
usePluginPreference,
} from '../../store'
import SVGStringRenderer, {SVGStringRendererNoRef} from './SVGStringRenderer'
import {
html_beautify,
// @ts-expect-error
} from 'js-beautify'
const HTML_BEAUTIFY_SETTINGS = {
'indent_size': '1',
'indent_char': '\t',
'max_preserve_newlines': '-1',
'preserve_newlines': false,
'end_with_newline': false,
}
import type {
SkaBlocks,
} from '../../types'
import {
type BlockModule,
type tImage,
type tBlockEditProps,
type tBlockSaveProps,
type tFile,
getPlaceholderSvg,
getAttachmentUrlBySizeSlug,
getAttachmentSizeSlugByUrl,
selectImage,
} from '@ska/shared'
import type {
CoreAlignAttributes,
} from '..'
import './style.scss'
const MODES = [
{
label: _x('File', 'Image block mode', 'ska-blocks'),
value: 'file',
},
{
label: _x('SVG', 'Image block mode', 'ska-blocks'),
value: 'svg',
},
{
label: _x('Icon', 'Image block mode', 'ska-blocks'),
value: 'icon',
},
{
label: _x('Lottie', 'Image block mode', 'ska-blocks'),
value: 'lottie',
},
{
label: _x('Placeholder', 'Image block mode', 'ska-blocks'),
value: 'placeholder',
},
]
const ROLES = [
{
label: _x('Figure', 'Image block role', 'ska-blocks'),
tooltip: _x(`Apply role="figure" to wrapper element, image should have an alt text provided.`, 'Image block role description', 'ska-blocks'),
value: 'figure',
},
{
label: _x('Presentation', 'Image block role', 'ska-blocks'),
tooltip: _x('Hide the image from assistive technologies.', 'Image block role description', 'ska-blocks'),
value: 'presentation',
},
{
label: _x('None', 'Image block role', 'ska-blocks'),
tooltip: _x(`Don't apply any semantics.`, 'Image block role description', 'ska-blocks'),
value: 'none',
},
]
const LINK_ROLES = ROLES.map(role => ({
...role,
...(role.value === 'figure' && {
label: _x('Link', 'Image block role', 'ska-blocks'),
tooltip: _x(`Image is wrapped in a link, provide an "aria-label" or "title" attribute, or fall back to the image's alt text.`, 'Image block role description', 'ska-blocks'),
}),
}))
const LOADING = [
{
label: _x('Lazy', 'Image loading attribute', 'ska-blocks'),
tooltip: _x(`Lazyload the image.`, 'Image loading description', 'ska-blocks'),
value: 'lazy',
},
{
label: _x('Eager', 'Image loading attribute', 'ska-blocks'),
tooltip: _x(`Don't lazyload the image.`, 'Image loading description', 'ska-blocks'),
value: 'eager',
},
{
label: _x('LCP', 'Image loading attribute', 'ska-blocks'),
tooltip: _x(`Enable high fetchpriority and synchronous decoding.`, 'Image loading description', 'ska-blocks'),
value: 'lcp',
},
{
label: _x('Preload', 'Image loading attribute', 'ska-blocks'),
tooltip: _x(`Preload the image in document head.`, 'Image loading description', 'ska-blocks'),
value: 'preload',
},
]
/** These need to match whatever is defined in `DynamicLinks.php` as `manual=true` links. */
const IMAGE_DYNAMIC_LINKS = [
{
label: _x('Attachment page', 'Image dynamic link name', 'ska-blocks'),
value: '#ska-link--media-attachment',
},
{
label: _x('Full size image', 'Image dynamic link name', 'ska-blocks'),
value: '#ska-link--media-file',
},
]
export interface ImageBlockAttributes extends LinkAttributes, AttributesAttributes, CoreAlignAttributes {
mode?: 'file' | 'svg' | 'icon' | 'lottie' | 'placeholder'
role?: 'figure' | 'presentation' | 'none'
/** Use featured image. */
featured?: boolean
featuredSize: string
featuredCrop: boolean
/** Whether to show placeholder on front end when featured image is missing. */
featuredPlaceholder: boolean
/** Skips rendering the block when post content contains `ska/image` with `featured` `true`. */
featuredExclusive: boolean
/** Meta key that contains image ID. */
metaKey?: string
width?: number
height?: number
svg?: string
id?: number
src?: string
alt?: string
/** Icon collection name. */
collection?: string
/** Icon name. */
icon?: string
/** Image loading mode. */
loading?: 'lazy' | 'eager' | 'lcp' | 'preload'
isPlaceholder?: boolean
/** Display larger image when clicking on the image. */
lightbox?: boolean
/** Wrap image in role="figure". */
wrap?: boolean
/** Shorthand to add `w-full h-full object-cover rounded-[inherit] aspect-[inherit]` to image when it has a wrapper. */
cover?: boolean
/** Add `srcset` to image when possible. */
srcset?: boolean
/** Add low res placeholder background to the image. */
placeholder?: boolean
/** Whether to preload low res placeholder in head. */
preloadPlaceholder?: boolean
/** Placeholder image index. */
placeholderIndex?: number
/** Sanitize svg. */
sanitize?: boolean
lottieControls?: boolean
lottieAutoplay?: boolean
lottieLoop?: boolean
lottieHover?: boolean
lottieMode?: 'normal' | 'bounce'
lottieSpeed?: number
lottieDirection?: 1 | -1
}
export const COVER_CLASSES = 'w-full h-full object-cover rounded-[inherit] aspect-[inherit]'
const FEATURED_IMAGE_PLACEHOLDER = '%SKA_FEATURED_IMAGE%'
const PLACEHOLDER_IMAGE_PLACEHOLDER = '%SKA_PLACEHOLDER_IMAGE%'
export const getPlaceholder = (args: {width?: string | number, height?: string | number, cover?: boolean, placeholderIndex?: number} = {}) => {
const {
width = '100%',
height = '100%',
cover = false,
placeholderIndex = 0,
} = args
if(placeholderIndex > 0 && placeholderImageUrl) {
// @ts-expect-error
return `
`
}
return getPlaceholderSvg({width, height, className: cover ? COVER_CLASSES : ''})
}
const getPlaceholderIcon = () => skaSvgIcon
const ACCEPT_LOTTIE = undefined
const ALLOWED_TYPES_LOTTIE = ['application/json', 'text/plain']
const LOTTIE_MODES = [
{
label: __('Normal', 'ska-blocks'),
value: 'normal',
},
{
label: __('Bounce', 'ska-blocks'),
value: 'bounce',
},
]
const LottiePlayerElement = 'lottie-player'
const getRoleProps = ({
role = 'figure',
mode = 'file',
isLink = false,
wrap = false,
}: {
role: ImageBlockAttributes['role'],
mode: ImageBlockAttributes['mode'],
isLink: boolean,
/** Are we wrapping or not, this isn't the block attribute `wrap` but the computed `shouldWrap`, e.g. in lightbox/link/icon mode we wrap regardless. */
wrap: boolean,
}) => {
// Plain svg is aria-hidden by default, but can be overridden with custom attribute.
if(mode === 'svg' && !wrap) {
return [
{'aria-hidden': true},
{},
]
}
// Icon is always wrapped.
if(mode === 'icon' && !isLink && role === 'figure') {
return [
{
role,
'aria-hidden': true, // For back-compat, otherwise would remove
},
{},
]
}
// Plain images with role=presentation.
if(mode === 'file' && !wrap && role === 'presentation') {
return [
{'aria-hidden': true},
{},
]
}
// Wrapped images with role=presentation.
if(mode === 'file' && wrap && !isLink && role === 'presentation') {
return [
{'aria-hidden': true},
{},
]
}
if(role === 'presentation' && wrap && !isLink) {
// Hide the wrapper element, hiding the contained media as well.
return [
{'aria-hidden': true},
{},
]
}
if(mode === 'placeholder' && wrap && !isLink) {
return [
{
...(role === 'figure' && {role}), // Back-compat
...(role === 'presentation' && {
'aria-hidden': true,
}),
},
{},
]
}
return [
{
// Apply the role for non-links.
...(wrap && !isLink && role !== 'none' && {
role,
}),
},
{
// Props for the media element inside the wrapper.
...wrap && {
// Hide media inside
...(role === 'presentation' && isLink && {
'aria-hidden': true,
}),
},
},
]
}
const ImageInspectorControls: React.FC> = props => {
const [isOpen, setIsOpen] = useState(false)
const {
attributes,
setAttributes,
context,
} = props
const {
postId,
postType,
} = context
const {
mode = 'placeholder',
role = 'figure',
featured = false,
featuredSize = 'full',
featuredCrop = true,
featuredPlaceholder = false,
metaKey = '',
width,
height,
id,
svg = '',
src,
alt = '',
collection,
icon,
loading,
lightbox = false,
wrap = true,
cover = false,
srcset = true,
placeholder = false,
preloadPlaceholder = true,
placeholderIndex = 0,
sanitize = true,
lottieControls = false,
lottieAutoplay = true,
lottieLoop = true,
lottieHover = false,
lottieMode = 'normal',
lottieSpeed = 1,
lottieDirection = 1,
} = attributes
const [_featuredImageId] = useEntityProp('postType', postType, 'featured_media', postId)
const postMeta = usePostMeta(postType, postId)
const metaValue = featured && metaKey && Object(postMeta).hasOwnProperty(metaKey) && !isNaN(postMeta[metaKey]) ? parseInt(postMeta[metaKey]) : undefined
const featuredImageId = metaKey ? metaValue : _featuredImageId
const media = useSelect(select => {
const mediaId = featured ? featuredImageId : id
if(mode !== 'file' || !mediaId) {
return undefined
}
return select(coreDataStore).getEntityRecord('postType', 'attachment', mediaId)
}, [mode, id, featured, featuredImageId])
/** Automatically set image width and height when they are missing but available. */
useEffect(() => {
if(media?.media_details?.sizes && src && !width && !height) {
const found = Object.keys(media.media_details.sizes).find(size => {
return media.media_details.sizes[size].source_url === src
})
if(found) {
const details = media.media_details.sizes[found]
const {
width: imageWidth = 0,
height: imageHeight = 0,
} = details
if(imageWidth > 0 && imageHeight > 0) {
setAttributes({width: imageWidth, height: imageHeight})
}
}
}
}, [media, src, width, height, setAttributes])
const isDataURL = src && src.startsWith('data:image')
const supportsResponsive = mode === 'file' && ((id && id > 0) || featured) && !isDataURL
const supportsLoading = mode === 'file' && ((src && !isDataURL) || featured)
const supportsLightbox = mode === 'file' && !isDataURL
const supportsPlaceholder = supportsResponsive && supportsLoading && !!media?.media_details?.sizes?.medium
const isLightbox = supportsLightbox && lightbox
const supportsSvgEdit = mode === 'svg' || (mode === 'icon' && svg)
const [Element] = useLinkWrapper(isLightbox ? 'a' : 'div', props)
const isLink = Element === 'a'
const shouldWrap = isLightbox || isLink || mode === 'icon' || mode === 'placeholder' ? true : wrap
const sanitizedSvg = useMemo(() => sanitize ? sanitizeSVG(svg) : svg, [svg, sanitize])
const svgIsAutoAriaHidden = svg && !shouldWrap && (
!('aria-hidden' in (attributes?.skaBlocksAttributes?.record || {}))
&& svg.indexOf('aria-hidden="false"') < 1
)
const svgInvalid = svg && !sanitizedSvg
return <>
{
value && setAttributes({
mode: value as ImageBlockAttributes['mode'],
/** Reset src when it's a placeholder value. */
...(src && src.indexOf('%SKA_') === 0 && {
src: '',
}),
/** Reset when switching between file/lottie mode. */
...(((mode === 'lottie' && value === 'file') || (mode === 'file' && value === 'lottie')) && {
id: undefined,
src: '',
}),
})
}}
/>
{/* Role is not applied to ``-s and unwrapped `
>
}
const Edit: React.FC> = props => {
const {
attributes,
setAttributes,
context,
isSelected,
} = props
const {
postId,
postType,
} = context
const {
mode = 'placeholder',
role = 'figure',
featured = false,
featuredSize = 'full',
featuredExclusive = false,
metaKey = '',
width,
height,
id,
svg = '',
src,
alt = '',
loading,
lightbox = false,
wrap = true,
cover = false,
placeholderIndex = 0,
sanitize = true,
lottieControls = false,
lottieAutoplay = true,
lottieLoop = true,
lottieHover = false,
lottieSpeed = 1,
lottieDirection = 1,
} = attributes
const {
children,
...blockProps
} = useBlockProps({className: 'image'})
const attributesProps = useAttributes(props)
const [_featuredImageId] = useEntityProp('postType', postType, 'featured_media', postId)
const postMeta = usePostMeta(postType, postId)
const metaValue = featured && metaKey && Object(postMeta).hasOwnProperty(metaKey) && !isNaN(postMeta[metaKey]) ? parseInt(postMeta[metaKey]) : undefined
const featuredImageId = metaKey ? metaValue : _featuredImageId
const [showPlaceholderImage] = usePluginPreference('showPlaceholderImage')
const media = useSelect(select => {
const mediaId = featured ? featuredImageId : id
if(mode !== 'file' || !mediaId) {
return undefined
}
return select(coreDataStore).getEntityRecord('postType', 'attachment', mediaId)
}, [mode, id, featured, featuredImageId])
const isDataURL = src && src.startsWith('data:image')
const supportsInlining = mode === 'file' && src && !isDataURL
const supportsLoading = mode === 'file' && ((src && !isDataURL) || featured)
const supportsLightbox = mode === 'file' && !isDataURL
const isLightbox = supportsLightbox && lightbox
const [Element, linkProps] = useLinkWrapper(isLightbox ? 'a' : 'div', props)
const isLink = Element === 'a'
const imgProps = {
src,
...(mode === 'file' && featured && {
src: '',
...(media && {
src: getAttachmentUrlBySizeSlug(media, featuredSize),
}),
}),
...(!!alt && {alt}),
...(width && width > 0 && {width}),
...(height && height > 0 && {height}),
...(supportsLoading && loading === 'lazy' && {loading}),
...((wrap && cover) && {className: COVER_CLASSES}),
}
const hasFile = !!imgProps.src
const onSelect = (selectedImage: tImage) => {
const image = selectImage(selectedImage, getAttachmentSizeSlugByUrl(media, src))
setAttributes({
id: image.id,
src: image.url,
width: image.width,
height: image.height,
mode: 'file',
...(image.alt && {alt: image.alt}),
})
}
const onSelectURL = (url: string) => {
setAttributes({
id: undefined,
src: url,
mode: 'file',
})
}
const onSelectLottie = (file: tFile) => {
setAttributes({
id: file.id,
src: file.url,
mode: 'lottie',
})
}
const onSelectLottieURL = (url: string) => {
setAttributes({
id: undefined,
src: url,
mode: 'lottie',
})
}
const onClear = () => {
setAttributes({
id: undefined,
src: undefined,
width: undefined,
height: undefined,
})
}
const hasLottie = !!src
const shouldWrap = isLightbox || isLink || mode === 'icon' || mode === 'placeholder' ? true : wrap
const shouldCover = (mode === 'file' || mode === 'placeholder') && shouldWrap && cover
const [roleProps, imgRoleProps] = getRoleProps({role, mode, isLink, wrap: shouldWrap})
const rootProps = {
...linkProps,
...attributesProps,
...blockProps,
}
const sanitizedSvg = useMemo(() => sanitize ? sanitizeSVG(svg) : svg, [svg, sanitize])
return <>
{mode === 'file' && featured && <>
setAttributes({metaKey: value})}
__nextHasNoMarginBottom
__next40pxDefaultSize
/>
setAttributes({featuredExclusive: !featuredExclusive})}
__nextHasNoMarginBottom
/>
>}
{mode === 'svg' && <>
setAttributes({sanitize: !sanitize})}
__nextHasNoMarginBottom
/>
>}
{supportsInlining && <>
{
setAttributes({
id: undefined,
src: dataURL,
})
}}
/>
>}
{!isLightbox && (
)}
{((mode === 'file' && !featured) || mode === 'placeholder') && (
{(!id && !src) && <>
>}
{(id || src) && <>
>}
)}
{mode === 'lottie' && hasLottie && (
)}
{mode === 'svg' && <>
{shouldWrap && <>
>}
{!shouldWrap && <>
>}
>}
{mode === 'icon' && <>
>}
{mode === 'file' && hasFile && <>
{shouldWrap && (
)}
{!shouldWrap && (
)}
>}
{((mode === 'file' && featured && !hasFile) || (mode === 'placeholder' && (!isSelected || showPlaceholderImage))) && (
)}
{((mode === 'placeholder' && isSelected && !showPlaceholderImage) || (mode === 'file' && !featured && !hasFile)) && (
)}
{mode === 'lottie' && !hasLottie && (
)}
{mode === 'lottie' && hasLottie && (
)}
>
}
const Save: React.FC> = props => {
const {
attributes,
} = props
const {
mode = 'placeholder',
role = 'figure',
featured = false,
width,
height,
svg,
src,
alt,
loading,
lightbox = false,
wrap = true,
cover = false,
placeholderIndex = 0,
sanitize = true,
lottieControls = false,
lottieAutoplay = true,
lottieLoop = true,
lottieHover = false,
lottieMode = 'normal',
lottieSpeed = 1,
lottieDirection = 1,
} = attributes
const {
children,
...blockProps
} = useBlockProps.save()
const isDataURL = src && src.startsWith('data:image')
const supportsLoading = mode === 'file' && ((src && !isDataURL) || featured)
const supportsLightbox = mode === 'file' && !isDataURL
const isLightbox = supportsLightbox && lightbox
const [Element, linkProps] = useLinkWrapper.save(isLightbox ? 'a' : 'div', props)
const isLink = Element === 'a'
const shouldWrap = lightbox || isLink || mode === 'icon' || mode === 'placeholder' ? true : wrap
const [roleProps, imgRoleProps] = getRoleProps({role, mode, isLink, wrap: shouldWrap})
const attributesProps = useAttributes.save(props)
if(mode === 'lottie') {
if(!src) {
return null
}
return (
{/* @ts-ignore */}
)
}
if(['placeholder', 'svg', 'icon'].includes(mode) && placeholderIndex === 0) {
const sanitizedSvg = sanitize ? sanitizeSVG(svg) : svg
if(mode === 'svg' && !shouldWrap) {
return (
)
}
return (
)
}
const imgProps = {
src,
...(mode === 'file' && featured && {
src: FEATURED_IMAGE_PLACEHOLDER,
}),
...(mode === 'placeholder' && placeholderIndex > 0 && {
src: PLACEHOLDER_IMAGE_PLACEHOLDER,
}),
...(!!alt && {alt}),
...(width && width > 0 && {width}),
...(height && height > 0 && {height}),
...(supportsLoading && loading === 'lazy' && {loading}),
...((wrap && cover) && {className: COVER_CLASSES}),
}
if(!imgProps.src) {
return null
}
return <>
{shouldWrap && (
)}
{!shouldWrap && (
)}
>
}
const defaultAttributes = {}
export default (skaBlocks: SkaBlocks): BlockModule => ({
metadata,
settings: {
edit: Edit,
save: Save,
variations: [
{
isDefault: true,
attributes: defaultAttributes,
},
{
name: 'icon',
title: __('Icon', 'ska-blocks'),
description: __('Scalable vector graphics that can be used as visual symbols.', 'ska-blocks'),
attributes: {
mode: 'icon',
role: 'presentation',
collection: 'heroicons',
...skaBlocks.tailwind.createBlockAttributes(
'grid place-items-center size-6 text-current',
`.grid{display:grid}.place-items-center{place-items:center}.size-6{width:var(--spacing-6);height:var(--spacing-6)}.text-current{color:currentColor}`
),
},
isActive: ({mode}) => mode === 'icon',
},
{
name: 'featured',
title: __('Featured image', 'ska-blocks'),
attributes: {
mode: 'file',
role: 'presentation',
featured: true,
},
// @ts-ignore
isActive: ({mode, featured, metaKey}) => mode === 'file' && featured && !metaKey,
},
],
transforms: {
from: [
{
type: 'block',
blocks: ['core/image'],
transform: ({id, url, alt, caption, width, height, align}) => {
return createBlock('ska/image', {
...defaultAttributes,
id,
src: url,
alt: alt || stripHTML(caption),
width,
height,
mode: 'file',
align,
})
},
},
{
type: 'block',
blocks: ['core/post-featured-image', 'woocommerce/product-image'],
transform: () => {
return createBlock('ska/image', {
...defaultAttributes,
mode: 'file',
featured: true,
})
},
},
],
to: [
{
type: 'block',
blocks: ['core/image'],
transform: (attributes) => {
const {id, src, alt, width, height, align} = attributes as ImageBlockAttributes
return createBlock('core/image', {
id,
url: src,
alt,
width,
height,
align,
})
},
},
],
},
__experimentalLabel: (attributes, {context}) => {
const customName = attributes?.metadata?.name
if(context === 'list-view' && customName) {
return customName
}
const {
mode,
className,
} = attributes
if(context === 'list-view') {
if(mode === 'file') {
const {
id,
src,
featured = false,
} = attributes
if(src && src.startsWith('data:image')) {
return _x('Inlined image', 'Block name in list view - Image block', 'ska-blocks')
}
/** Notify when ID is specified but not found in media library (debug only). */
if(IS_DEBUG && !featured && id) {
const args = ['postType', 'attachment', id]
// @ts-expect-error
const media = select(coreDataStore).getEntityRecord(...args)
// @ts-expect-error
const resolutionStatus = select(coreDataStore).getResolutionState('getEntityRecord', args)?.status
if(resolutionStatus === 'error') {
return `${_x('Image', 'Block name in list view - Image block', 'ska-blocks')} ❗ ${sprintf(_x('Media (%d) not found', 'ID', 'ska-blocks'), id)}`
}
}
}
if(mode === 'icon') {
const {
collection,
icon,
} = attributes
if(collection && icon) {
const fullIconName = `${collection}/${icon}`
const parts = fullIconName.split('/')
const iconName = parts[parts.length - 1]
if(iconName) {
return sprintf(_x(`Icon (%s)`, 'Block name in list view - Image block (icon name)', 'ska-blocks'), iconName)
}
}
}
if(mode === 'svg') {
const renderClassName = className ? `.${className.split(' ').join('.')}` : ''
const tagName = `svg${renderClassName}`
return sprintf(_x(`<%s>`, 'Block name in list view - HTML element', 'ska-blocks'), tagName)
}
if(mode === 'placeholder') {
return _x('Placeholder image', 'Block name in list view - Image block', 'ska-blocks')
}
if(mode === 'lottie') {
return _x('Lottie', 'Block name in list view - Image block', 'ska-blocks')
}
}
},
},
})