diff --git a/packages/components/src/icon-picker-control/README.md b/packages/components/src/icon-picker-control/README.md index bf1e8733..e46e5c41 100644 --- a/packages/components/src/icon-picker-control/README.md +++ b/packages/components/src/icon-picker-control/README.md @@ -1,24 +1,72 @@ -# Icon picker control +# Icon Picker Control -The IconPickerControl use the Font Awesome API to search for icons. More information found here: . +Picks an icon in the block editor. Supports two modes: -## Hooks +- **FontAwesome mode** (default, legacy): searches the FontAwesome GraphQL API and returns a CSS class string (e.g. `fa-solid fa-house`). Stored in the `icon` block attribute. +- **SVG mode** (new): uses the `wp-icons` REST API (`/yard/icons`) to search sets of SVG icons and returns raw SVG markup. Stored in the `iconSVG` block attribute. -By default all the styles from Font Awesome are loaded. Use this filter to change which styles are needed. In this example all styles are loaded: +Mode is selected automatically. If `/yard/icons` is reachable **and** the consumer provides an `onChangeSVG` callback, SVG mode is used. Otherwise FontAwesome mode is used. Existing consumers that only provide `onChange` are unaffected. -```JS +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `onChange` | `Function` | — | Called with FA class string (FontAwesome mode). | +| `icon` | `string` | — | Current FA class string, used for the legacy icon preview. | +| `displayIconPreview` | `boolean` | `true` | Show a preview of the selected icon above the search field. | +| `displayAsPopover` | `boolean` | `true` | Show search results in a popover (vs. inline). | +| `displayDeleteIcon` | `boolean` | `false` | Show a delete button below the search field. | +| `handleRemove` | `Function` | — | Called when the delete button is clicked. | +| `onChangeSVG` | `Function` | — | Called with raw SVG string (SVG mode). Opts the consumer into SVG mode when provided. | +| `iconSVG` | `string` | — | Current SVG markup, used for the SVG icon preview. | + +## New consumer example + +```jsx + setAttributes({ icon: v }) } + onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } +/> +``` + +### Block attribute registration + +```js +attributes: { + icon: { type: 'string', default: '' }, + iconSVG: { type: 'string', default: '' }, +} +``` + +### Frontend rendering (handle both old and new saved content) + +```jsx +{ iconSVG + ? + : icon && +} +``` + +## FontAwesome mode — filter + +To limit which FontAwesome family/style combinations appear, use this WordPress filter: + +```js import { addFilter } from '@wordpress/hooks'; -addFilter('yard.fontawesome-family-styles', 'yard', () => [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - { family: 'classic', style: 'light' }, - { family: 'classic', style: 'thin' }, - { family: 'classic', style: 'brands' }, - { family: 'duotone', style: 'solid' }, - { family: 'sharp', style: 'solid' }, - { family: 'sharp', style: 'regular' }, - { family: 'sharp', style: 'light' }, - { family: 'sharp', style: 'thin' }, -]); +addFilter( 'yard.fontawesome-family-styles', 'yard', () => [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, +] ); ``` + +## SVG mode — how it works + +1. On mount, fetches `/yard/icons` to get available icon sets. +2. Renders a set selector (`SelectControl`) populated with the returned sets. +3. Search fires after 3 characters, debounced by 300 ms. +4. Shows the first 10 results; fetches each icon's SVG eagerly (with skeleton placeholders while loading). +5. SVGs are cached in a module-level `Map` for the page session — the same icon is never fetched twice. +6. Selecting an icon calls `onChangeSVG` with the raw SVG string. diff --git a/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx b/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx new file mode 100644 index 00000000..dad3c4eb --- /dev/null +++ b/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx @@ -0,0 +1,142 @@ +/** + * WordPress dependencies + */ +import { Popover, SearchControl } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import DeleteIcon from './delete-icon.jsx'; +import IconResults from './icon-results.jsx'; +import { getFontAwesomeIcons } from '../utils/api'; +import { convertResponseToClassnames } from '../utils/helpers'; + +const FontAwesomeIconPicker = ( { + onChange, + icon, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, +} ) => { + const [ isOpen, setOpen ] = useState( false ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ searchResults, setSearchResults ] = useState( [] ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const { createNotice } = useDispatch( noticesStore ); + + const allowedFamilyStyles = applyFilters( + 'yard.fontawesome-family-styles', + [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, + { family: 'classic', style: 'light' }, + { family: 'classic', style: 'thin' }, + { family: 'classic', style: 'brands' }, + { family: 'duotone', style: 'solid' }, + { family: 'sharp', style: 'solid' }, + { family: 'sharp', style: 'regular' }, + { family: 'sharp', style: 'light' }, + { family: 'sharp', style: 'thin' }, + ] + ); + + const searchFontAwesomeIcons = async ( searchValue ) => { + try { + const response = await getFontAwesomeIcons( searchValue ); + if ( ! response ) return; + + const result = response?.data?.search.reduce( + ( iconResults, iconData ) => { + convertResponseToClassnames( + iconData, + allowedFamilyStyles + ).forEach( ( value ) => { + iconResults.push( value ); + } ); + + return iconResults; + }, + [] + ); + if ( ! result ) return; + + setSearchResults( result ); + setOpen( true ); + } catch ( err ) { + return showErrorNotice(); + } + }; + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + const handleIconClick = ( clickedIcon ) => { + onChange( clickedIcon ); + setSearchInput( () => '' ); + setOpen( () => false ); + }; + + return ( + <> + { displayIconPreview && icon && ( + + ) } + + { + setSearchInput( searchValue ); + searchFontAwesomeIcons( searchValue ); + } } + ref={ setPopoverAnchor } + /> + + { displayAsPopover && searchInput && isOpen && ( + setOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && icon && ( + + ) } + + ); +}; + +export default FontAwesomeIconPicker; diff --git a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx new file mode 100644 index 00000000..3f7d389b --- /dev/null +++ b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx @@ -0,0 +1,224 @@ +/** + * WordPress dependencies + */ +import { SelectControl, SearchControl, Popover } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useState, useRef, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import DeleteIcon from './delete-icon.jsx'; +import SvgIconResults from './svg-icon-results.jsx'; +import { searchIcons, getIconSvg } from '../utils/api'; +import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; +import sanitizeSvg from '../utils/sanitize-svg'; + +const SEARCH_MIN_LENGTH = 2; +const SEARCH_DEBOUNCE_MS = 100; +const MAX_RESULTS = 50; + +const SvgIconPicker = ( { + sets, + onChangeSVG, + iconSVG, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, +} ) => { + const setOptions = Object.keys( sets ).map( ( key ) => ( { + label: key, + value: key, + } ) ); + + const [ selectedSet, setSelectedSet ] = useState( + setOptions[ 0 ]?.value ?? '' + ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ results, setResults ] = useState( [] ); + const [ isOpen, setIsOpen ] = useState( false ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const abortControllerRef = useRef( null ); + const debounceTimerRef = useRef( null ); + const searchGenerationRef = useRef( 0 ); + + const { createNotice } = useDispatch( noticesStore ); + + useEffect( () => { + return () => { + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + }; + }, [] ); + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + /** + * Fetch SVGs for a list of icon objects, using the cache where possible. + * Returns the same list with a `svg` property added (string or 'ERROR'). + * + * @param {Array} iconList - Array of { name, set, ... } objects. + * @return {Promise} The icon list with resolved SVG strings. + */ + const fetchSvgsForResults = ( iconList ) => { + return Promise.all( + iconList.map( async ( iconData ) => { + const { name, set } = iconData; + if ( hasCachedSvg( set, name ) ) { + return { ...iconData, svg: getCachedSvg( set, name ) }; + } + try { + const svg = await getIconSvg( set, name ); + setCachedSvg( set, name, svg ); + return { ...iconData, svg }; + } catch { + return { ...iconData, svg: 'ERROR' }; + } + } ) + ); + }; + + const performSearch = async ( set, query, signal ) => { + const generation = ++searchGenerationRef.current; + try { + const iconList = await searchIcons( set, query, signal ); + if ( generation !== searchGenerationRef.current ) return; + const limited = iconList.slice( 0, MAX_RESULTS ); + + // Render immediately with skeleton placeholders. + setResults( limited.map( ( i ) => ( { ...i, svg: null } ) ) ); + setIsOpen( true ); + + // Then resolve SVGs and update. + const withSvgs = await fetchSvgsForResults( limited ); + if ( generation !== searchGenerationRef.current ) return; + setResults( withSvgs ); + } catch ( err ) { + if ( err.name === 'AbortError' ) return; + showErrorNotice(); + } + }; + + const handleSearchChange = ( value ) => { + setSearchInput( value ); + + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + + if ( value.length < SEARCH_MIN_LENGTH ) { + setResults( [] ); + setIsOpen( false ); + return; + } + + debounceTimerRef.current = setTimeout( () => { + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + performSearch( + selectedSet, + value, + abortControllerRef.current.signal + ); + }, SEARCH_DEBOUNCE_MS ); + }; + + const handleSetChange = ( value ) => { + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + searchGenerationRef.current++; + setSelectedSet( value ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + const handleIconClick = ( svg ) => { + onChangeSVG( svg ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + return ( + <> + { displayIconPreview && iconSVG && ( + + ) } + + + + + + { displayAsPopover && searchInput && isOpen && ( + setIsOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && iconSVG && ( + + ) } + + ); +}; + +export default SvgIconPicker; diff --git a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx new file mode 100644 index 00000000..6b0af600 --- /dev/null +++ b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import sanitizeSvg from '../utils/sanitize-svg'; + +const SvgIconResults = ( { results, onIconClick } ) => { + return ( +
+ { results.map( ( { name, set, svg } ) => ( +
+ +
+ ) ) } + + { ! results.length && ( +

{ __( 'Er zijn geen iconen gevonden' ) }

+ ) } +
+ ); +}; + +export default SvgIconResults; diff --git a/packages/components/src/icon-picker-control/editor.css b/packages/components/src/icon-picker-control/editor.css index 4f5b9423..8e67cbee 100644 --- a/packages/components/src/icon-picker-control/editor.css +++ b/packages/components/src/icon-picker-control/editor.css @@ -38,3 +38,44 @@ .icon-picker-control-delete-icon-btn { margin-bottom: 1.5rem; } + +/* SVG icon preview (replaces for SVG-mode selected icon) */ +.icon-picker-control-preview-icon svg { + width: 3rem; + height: 3rem; + display: block; + margin-bottom: 1rem; +} + +/* Skeleton placeholder for SVG cells that are still loading */ +.icon-picker-control-svg-skeleton { + display: block; + width: 2rem; + height: 2rem; + background-color: #e0e0e0; + border-radius: 2px; + animation: icon-picker-skeleton-pulse 1.2s ease-in-out infinite; +} + +@keyframes icon-picker-skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Error placeholder for failed SVG fetches */ +.icon-picker-control-svg-error { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + font-size: 1.2rem; + color: #757575; +} + +/* Ensure SVGs inside result buttons scale correctly */ +.icon-picker-control-icon-btn-container svg { + width: 2rem; + height: 2rem; + display: block; +} diff --git a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js new file mode 100644 index 00000000..483d8fd6 --- /dev/null +++ b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getIconSets } from '../utils/api'; + +/** + * Detects whether the wp-icons REST API is available by fetching /yard/icons. + * + * Returns: + * - isLoading: true while the request is in flight (render nothing during this time) + * - isNewApiAvailable: true when /yard/icons returned at least one set + * - sets: the map of set name → metadata, e.g. { "fa-solid": { prefix: "fas" } } + * + * @return {{ sets: Object, isNewApiAvailable: boolean, isLoading: boolean }} The icon sets data and API availability status. + */ +const useIconSets = () => { + const [ sets, setSets ] = useState( {} ); + const [ isNewApiAvailable, setIsNewApiAvailable ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); + + useEffect( () => { + let isMounted = true; + const controller = new AbortController(); + const timeoutId = setTimeout( () => controller.abort(), 5000 ); + + getIconSets( controller.signal ) + .then( ( data ) => { + if ( ! isMounted ) return; + if ( data && Object.keys( data ).length > 0 ) { + setSets( data ); + setIsNewApiAvailable( true ); + } + } ) + .catch( ( err ) => { + if ( isMounted && err.name !== 'AbortError' ) { + // API not available — will fall back to FontAwesome picker silently. + } + } ) + .finally( () => { + if ( ! isMounted ) return; + setIsLoading( false ); + } ); + + return () => { + clearTimeout( timeoutId ); + isMounted = false; + controller.abort(); + }; + }, [] ); + + return { sets, isNewApiAvailable, isLoading }; +}; + +export default useIconSets; diff --git a/packages/components/src/icon-picker-control/index.jsx b/packages/components/src/icon-picker-control/index.jsx index 927d6eca..b922567f 100644 --- a/packages/components/src/icon-picker-control/index.jsx +++ b/packages/components/src/icon-picker-control/index.jsx @@ -1,29 +1,36 @@ /** * WordPress dependencies */ -import { - Dropdown, - Popover, - SearchControl, - ToolbarButton, - ToolbarGroup, -} from '@wordpress/components'; +import { Dropdown, ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { BlockControls } from '@wordpress/block-editor'; -import { useDispatch } from '@wordpress/data'; -import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { applyFilters } from '@wordpress/hooks'; -import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import DeleteIcon from './components/delete-icon.jsx'; -import IconResults from './components/icon-results.jsx'; -import { getFontAwesomeIcons } from './utils/api'; -import { convertResponseToClassnames } from './utils/helpers'; +import FontAwesomeIconPicker from './components/font-awesome-icon-picker.jsx'; +import SvgIconPicker from './components/svg-icon-picker.jsx'; +import useIconSets from './hooks/use-icon-sets.js'; import './editor.css'; +/** + * Core icon picker control. Automatically selects between the FontAwesome + * picker and the new SVG-based picker depending on: + * 1. Whether /yard/icons is reachable (detected on mount). + * 2. Whether the consumer has provided an `onChangeSVG` callback. + * + * If either condition is false, the FontAwesome picker is rendered. + * + * @param {Object} props + * @param {Function} props.onChange Called with FA class string when FA picker is used. + * @param {string} props.icon Current FA class string (for legacy preview). + * @param {boolean} props.displayIconPreview Show icon preview above search. Default true. + * @param {boolean} props.displayAsPopover Show results in a popover. Default true. + * @param {boolean} props.displayDeleteIcon Show delete button. Default false. + * @param {Function} props.handleRemove Called when delete button is clicked. + * @param {Function} props.onChangeSVG Called with raw SVG string when SVG picker is used. + * @param {string} props.iconSVG Current SVG markup (for SVG preview). + */ export const IconPickerControl = ( { onChange, icon, @@ -31,119 +38,38 @@ export const IconPickerControl = ( { displayAsPopover = true, displayDeleteIcon = false, handleRemove, + onChangeSVG, + iconSVG, } ) => { - const [ isOpen, setOpen ] = useState( false ); - const [ searchInput, setSearchInput ] = useState( '' ); - const [ searchResults, setSearchResults ] = useState( [] ); - const [ popoverAnchor, setPopoverAnchor ] = useState(); - - const { createNotice } = useDispatch( noticesStore ); - - const allowedFamilyStyles = applyFilters( - 'yard.fontawesome-family-styles', - [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - { family: 'classic', style: 'light' }, - { family: 'classic', style: 'thin' }, - { family: 'classic', style: 'brands' }, - { family: 'duotone', style: 'solid' }, - { family: 'sharp', style: 'solid' }, - { family: 'sharp', style: 'regular' }, - { family: 'sharp', style: 'light' }, - { family: 'sharp', style: 'thin' }, - ] - ); - - const searchFontAwesomeIcons = async ( searchValue ) => { - try { - const response = await getFontAwesomeIcons( searchValue ); - if ( ! response ) return; - - const result = response?.data?.search.reduce( - ( iconResults, iconData ) => { - convertResponseToClassnames( - iconData, - allowedFamilyStyles - ).forEach( ( value ) => { - iconResults.push( value ); - } ); - - return iconResults; - }, - [] - ); - if ( ! result ) return; - - setSearchResults( result ); - setOpen( true ); - } catch ( err ) { - return showErrorNotice(); - } - }; - - const showErrorNotice = () => { - createNotice( - 'error', - __( - 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' - ), - { - isDismissible: true, - type: 'snackbar', - id: 'icon-picker-control-error', - } + const { sets, isNewApiAvailable, isLoading } = useIconSets(); + + if ( isLoading ) { + return null; + } + + if ( isNewApiAvailable && onChangeSVG ) { + return ( + ); - }; - - const handleIconClick = ( clickedIcon ) => { - onChange( clickedIcon ); - setSearchInput( () => '' ); - setOpen( () => false ); - }; + } return ( - <> - { displayIconPreview && icon && ( - - ) } - - { - setSearchInput( searchValue ); - searchFontAwesomeIcons( searchValue ); - } } - ref={ setPopoverAnchor } - /> - - { displayAsPopover && searchInput && isOpen && ( - setOpen( false ) } - focusOnMount={ false } - > - - - ) } - - { ! displayAsPopover && searchInput && ( - - ) } - - { displayDeleteIcon && icon && ( - - ) } - + ); }; @@ -152,6 +78,8 @@ export const IconPickerControlInspector = ( { onChange, displayDeleteIcon = false, handleRemove, + onChangeSVG, + iconSVG, } ) => { return ( ); }; -export const IconPickerControlToolbar = ( { icon, onChange } ) => { +export const IconPickerControlToolbar = ( { + icon, + onChange, + onChangeSVG, + iconSVG, +} ) => { return ( { onChange={ onChange } displayIconPreview={ false } displayAsPopover={ false } + onChangeSVG={ onChangeSVG } + iconSVG={ iconSVG } /> ) } /> diff --git a/packages/components/src/icon-picker-control/utils/api.js b/packages/components/src/icon-picker-control/utils/api.js index 14c936a0..9e576072 100644 --- a/packages/components/src/icon-picker-control/utils/api.js +++ b/packages/components/src/icon-picker-control/utils/api.js @@ -41,3 +41,62 @@ export const getFontAwesomeIcons = async ( search ) => { throw new Error( error ); } }; + +/** + * Fetch all registered icon sets from the wp-icons REST API. + * + * @param {AbortSignal} [signal] - AbortController signal to cancel the request. + * + * @return {Promise} Map of set name to set metadata, e.g. { "fa-solid": { prefix: "fas" } } + */ +export const getIconSets = async ( signal ) => { + const res = await fetch( '/yard/icons', { signal } ); + if ( ! res.ok ) { + throw new Error( `Failed to fetch icon sets: ${ res.status }` ); + } + return res.json(); +}; + +/** + * Search icons within a set via the wp-icons REST API. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} query - The search query. + * @param {AbortSignal} [signal] - AbortController signal to cancel in-flight requests. + * + * @return {Promise} Array of icon objects: [{ name, set, prefix }, ...] + */ +export const searchIcons = async ( set, query, signal ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }?q=${ encodeURIComponent( + query + ) }`, + { signal } + ); + if ( ! res.ok ) { + throw new Error( `Failed to search icons: ${ res.status }` ); + } + return res.json(); +}; + +/** + * Fetch the raw SVG markup for a single icon. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} name - The icon name, e.g. "house". + * + * @return {Promise} Raw SVG markup string. + */ +export const getIconSvg = async ( set, name ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }/${ encodeURIComponent( + name + ) }` + ); + if ( ! res.ok ) { + throw new Error( + `Failed to fetch SVG for ${ set }/${ name }: ${ res.status }` + ); + } + return res.text(); +}; diff --git a/packages/components/src/icon-picker-control/utils/sanitize-svg.js b/packages/components/src/icon-picker-control/utils/sanitize-svg.js new file mode 100644 index 00000000..a9d78932 --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/sanitize-svg.js @@ -0,0 +1,17 @@ +/** + * Basic SVG sanitization — strips script tags, event handler attributes, + * and javascript: URI values. + * TODO: Replace with DOMPurify when available as a project dependency. + * Note: Does not cover external resource references (). + * + * @param {string} svg - Raw SVG string. + * @return {string} Sanitized SVG string. + */ +const sanitizeSvg = ( svg ) => { + return svg + .replace( //gi, '' ) + .replace( /\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '' ) + .replace( /\bhref\s*=\s*["']?\s*javascript:[^"'\s>]*/gi, '' ); +}; + +export default sanitizeSvg; diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.js b/packages/components/src/icon-picker-control/utils/svg-cache.js new file mode 100644 index 00000000..0ebe1345 --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/svg-cache.js @@ -0,0 +1,49 @@ +/** + * Module-level SVG cache shared across all IconPickerControl instances. + * Persists for the lifetime of the page session. + * + * @type {Map} + */ +const svgCache = new Map(); + +/** + * Retrieve a cached SVG string. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {string|undefined} Cached SVG string, or undefined if not cached. + */ +export const getCachedSvg = ( set, name ) => + svgCache.get( `${ set }/${ name }` ); + +/** + * Store an SVG string in the cache. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * @param {string} svg - Raw SVG markup. + * + * @return {void} + */ +export const setCachedSvg = ( set, name, svg ) => + svgCache.set( `${ set }/${ name }`, svg ); + +/** + * Check whether an SVG is already cached. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {boolean} Whether the SVG is cached. + */ +export const hasCachedSvg = ( set, name ) => + svgCache.has( `${ set }/${ name }` ); + +/** + * Clear all entries from the cache. + * Intended for use in tests only. + * + * @return {void} + */ +export const clearCache = () => svgCache.clear(); diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.test.js b/packages/components/src/icon-picker-control/utils/svg-cache.test.js new file mode 100644 index 00000000..6170b32a --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/svg-cache.test.js @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { + getCachedSvg, + setCachedSvg, + hasCachedSvg, + clearCache, +} from './svg-cache'; + +describe( 'svg-cache', () => { + beforeEach( () => clearCache() ); + + test( 'hasCachedSvg returns false for an uncached icon', () => { + expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( false ); + } ); + + test( 'getCachedSvg returns undefined for an uncached icon', () => { + expect( getCachedSvg( 'fa-solid', 'house' ) ).toBeUndefined(); + } ); + + test( 'setCachedSvg stores an SVG and hasCachedSvg returns true', () => { + setCachedSvg( 'fa-solid', 'house', 'house' ); + expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( true ); + } ); + + test( 'getCachedSvg retrieves the stored SVG string', () => { + setCachedSvg( 'fa-solid', 'star', 'star' ); + expect( getCachedSvg( 'fa-solid', 'star' ) ).toBe( 'star' ); + } ); + + test( 'cache is keyed by set/name, different sets are stored separately', () => { + setCachedSvg( 'fa-solid', 'heart', 'solid-heart' ); + setCachedSvg( 'fa-brands', 'heart', 'brands-heart' ); + expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( + 'solid-heart' + ); + expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( + 'brands-heart' + ); + } ); +} ); diff --git a/packages/components/src/icon/index.jsx b/packages/components/src/icon/index.jsx index caa53965..0f170267 100644 --- a/packages/components/src/icon/index.jsx +++ b/packages/components/src/icon/index.jsx @@ -1,6 +1,17 @@ export const Icon = ( props ) => { const { attributes } = props; - const { icon, iconAltText } = attributes; + const { icon, iconSVG, iconAltText } = attributes; + + if ( iconSVG ) { + return ( +