diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index ed657c648b1a12..2c6342e58e18fd 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -431,6 +431,15 @@ Add custom HTML code and preview it as you edit. ([Source](https://github.com/Wo - **Supports:** interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~ - **Attributes:** content +## Icon + +Insert an SVG icon or graphic. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/icon)) + +- **Name:** core/icon +- **Category:** media +- **Supports:** align, anchor, interactivity (clientNavigation), spacing (margin, padding), ~~html~~ +- **Attributes:** icon, iconName, label, title + ## Image Insert an image to make a visual statement. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/image)) @@ -521,7 +530,7 @@ A collection of blocks that allow visitors to get around your site. ([Source](ht - **Name:** core/navigation - **Category:** theme -- **Allowed Blocks:** core/navigation-link, core/search, core/social-links, core/page-list, core/spacer, core/home-link, core/site-title, core/site-logo, core/navigation-submenu, core/loginout, core/buttons +- **Allowed Blocks:** core/navigation-link, core/search, core/social-links, core/page-list, core/spacer, core/home-link, core/icon, core/site-title, core/site-logo, core/navigation-submenu, core/loginout, core/buttons - **Supports:** align (full, wide), ariaLabel, contentRole, inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~, ~~renaming~~ - **Attributes:** __unstableLocation, backgroundColor, customBackgroundColor, customOverlayBackgroundColor, customOverlayTextColor, customTextColor, hasIcon, icon, maxNestingLevel, openSubmenusOnClick, overlayBackgroundColor, overlayMenu, overlayTextColor, ref, rgbBackgroundColor, rgbTextColor, showSubmenuIcon, templateLock, textColor diff --git a/package-lock.json b/package-lock.json index d21fdeec74fb4d..ec9dae66be2f3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27004,6 +27004,114 @@ "resolved": "https://registry.npmjs.org/hpq/-/hpq-1.3.0.tgz", "integrity": "sha512-fvYTvdCFOWQupGxqkahrkA+ERBuMdzkxwtUdKrxR6rmMd4Pfl+iZ1QiQYoaZ0B/v0y59MOMnz3XFUWbT50/NWA==" }, + "node_modules/html-dom-parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.1.tgz", + "integrity": "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "10.0.0" + } + }, + "node_modules/html-dom-parser/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/html-dom-parser/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -27065,6 +27173,54 @@ "node": ">= 12" } }, + "node_modules/html-react-parser": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.6.tgz", + "integrity": "sha512-qcpPWLaSvqXi+TndiHbCa+z8qt0tVzjMwFGFBAa41ggC+ZA5BHaMIeMJla9g3VSp4SmiZb9qyQbmbpHYpIfPOg==", + "license": "MIT", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "5.1.1", + "react-property": "2.0.2", + "style-to-js": "1.1.17" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/html-react-parser/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/html-react-parser/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -27731,6 +27887,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -40031,6 +40193,12 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -43608,6 +43776,24 @@ "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==" }, + "node_modules/style-to-js": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", + "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.9" + } + }, + "node_modules/style-to-object": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", + "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/stylehacks": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.0.tgz", @@ -49773,6 +49959,7 @@ "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", + "html-react-parser": "^5.2.6", "memize": "^2.1.0", "remove-accents": "^0.5.0", "uuid": "^9.0.1" diff --git a/packages/block-library/package.json b/packages/block-library/package.json index fa311ce4ff7470..03ad6a8cf4d81b 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -81,6 +81,7 @@ "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", + "html-react-parser": "^5.2.6", "memize": "^2.1.0", "remove-accents": "^0.5.0", "uuid": "^9.0.1" diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index feea74b786a4a3..6c6858620d2baf 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -21,6 +21,7 @@ @import "./gallery/editor.scss"; @import "./group/editor.scss"; @import "./html/editor.scss"; +@import "./icon/editor.scss"; @import "./image/editor.scss"; @import "./latest-posts/editor.scss"; @import "./media-text/editor.scss"; diff --git a/packages/block-library/src/icon/block.json b/packages/block-library/src/icon/block.json new file mode 100644 index 00000000000000..187e15eabd88ff --- /dev/null +++ b/packages/block-library/src/icon/block.json @@ -0,0 +1,61 @@ +{ + "apiVersion": 3, + "$schema": "https://schemas.wp.org/trunk/block.json", + "name": "core/icon", + "title": "Icon", + "category": "media", + "description": "Insert an SVG icon or graphic.", + "keywords": [ "icon", "svg" ], + "attributes": { + "icon": { + "type": "string", + "source": "html", + "selector": ".wp-block-icon", + "default": "", + "role": "content" + }, + "iconName": { + "type": "string", + "role": "content" + }, + "label": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "supports": { + "anchor": true, + "align": true, + "html": false, + "interactivity": { + "clientNavigation": true + }, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true, + "__experimentalSkipSerialization": true, + "__experimentalDefaultControls": { + "color": false, + "radius": false, + "style": false, + "width": false + } + }, + "spacing": { + "padding": true, + "margin": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + } + }, + "textdomain": "icon-block", + "editorScript": "file:./index.js", + "editorStyle": "file:./editor.css", + "style": "file:./style.css" +} diff --git a/packages/block-library/src/icon/components/custom-inserter/index.js b/packages/block-library/src/icon/components/custom-inserter/index.js new file mode 100644 index 00000000000000..5be19db1ae3a50 --- /dev/null +++ b/packages/block-library/src/icon/components/custom-inserter/index.js @@ -0,0 +1,170 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Button, + Modal, + Notice, + RangeControl, + TextareaControl, +} from '@wordpress/components'; +import { useEffect, useState } from '@wordpress/element'; +import { Icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { bolt } from './../../icons/bolt'; +import { parseIcon } from './../../utils'; + +export default function CustomInserterModal( props ) { + const { + isCustomInserterOpen, + setCustomInserterOpen, + attributes, + setAttributes, + } = props; + const { icon, iconName } = attributes; + const [ customIcon, setCustomIcon ] = useState( ! iconName ? icon : '' ); + const [ iconSize, setIconSize ] = useState( 100 ); + + // Reset values when modal is closed. + useEffect( () => { + if ( ! isCustomInserterOpen ) { + setIconSize( 100 ); + } + }, [ isCustomInserterOpen ] ); + + // If a SVG icon is inserted from the Media Library, we need to update + // the custom icon editor in the modal. + useEffect( () => setCustomIcon( icon ), [ icon ] ); + + if ( ! isCustomInserterOpen ) { + return null; + } + + const insertCustomIcon = () => { + setAttributes( { + icon: customIcon, + iconName: '', + } ); + setCustomInserterOpen( false ); + }; + + const closeModal = () => { + setCustomInserterOpen( false ); + }; + + let iconToRender = parseIcon( customIcon ); + const isSVG = + iconToRender?.props && Object.keys( iconToRender.props ).length > 0; + + // Render the default lightning bolt if the icon is not a valid SVG. + iconToRender = isSVG ? iconToRender : bolt; + + return ( + +
+
+ +
+
+ + { customIcon && ! isSVG && ( + + { __( + 'The custom icon does not appear to be in a valid SVG format or contains non-SVG elements.' + ) } + + ) } + setCustomIcon( '' ) } + onInsert={ insertCustomIcon } + /> +
+
+
+ ); +} + +function IconPreview( { iconToRender, iconSize, setIconSize, isSVG } ) { + return ( +
+
+ +
+
+
+ { __( 'Preview size' ) } + +
+
+
+ ); +} + +function IconInsertButtons( { customIcon, isSVG, onClear, onInsert } ) { + return ( +
+ + +
+ ); +} diff --git a/packages/block-library/src/icon/components/icon-dropzone/index.js b/packages/block-library/src/icon/components/icon-dropzone/index.js new file mode 100644 index 00000000000000..3de03ae45ae050 --- /dev/null +++ b/packages/block-library/src/icon/components/icon-dropzone/index.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { DropZone } from '@wordpress/components'; +import { isBlobURL } from '@wordpress/blob'; + +/** + * Internal dependencies + */ +import { + parseUploadedMediaAndSetIcon, + parseDroppedMediaAndSetIcon, + displayMessages, +} from './../../utils'; + +export default function IconDropZone( props ) { + const { attributes, setAttributes, mediaUpload, isSVGUploadAllowed } = + props; + + function onDropFiles( filesList ) { + if ( isSVGUploadAllowed ) { + mediaUpload( { + allowedTypes: [ 'image/svg+xml' ], + filesList, + onFileChange( [ image ] ) { + if ( isBlobURL( image?.url ) ) { + return; + } + parseUploadedMediaAndSetIcon( + image, + attributes, + setAttributes + ); + }, + onError( message ) { + createErrorNotice( message, { type: 'snackbar' } ); + }, + } ); + } else { + const reader = new window.FileReader(); + reader.readAsText( filesList[ 0 ] ); + reader.onload = ( e ) => { + const fileContent = e?.target?.result ?? ''; + parseDroppedMediaAndSetIcon( fileContent, setAttributes ); + }; + } + } + + const label = isSVGUploadAllowed + ? __( 'Drop SVG file to upload' ) + : __( 'Drop SVG file to insert' ); + + return ; +} diff --git a/packages/block-library/src/icon/components/icon-placeholder/index.js b/packages/block-library/src/icon/components/icon-placeholder/index.js new file mode 100644 index 00000000000000..64032769e5b7a9 --- /dev/null +++ b/packages/block-library/src/icon/components/icon-placeholder/index.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Placeholder } from '@wordpress/components'; +import { MediaUpload } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { bolt } from './../../icons/bolt'; +import { parseUploadedMediaAndSetIcon } from '../../utils'; +import QuickInserterPopover from './../quick-inserter'; + +export default function IconPlaceholder( props ) { + const { + setInserterOpen, + isQuickInserterOpen, + setQuickInserterOpen, + setCustomInserterOpen, + attributes, + setAttributes, + enableCustomIcons, + isSVGUploadAllowed, + } = props; + + const instructions = () => { + const messages = { + default: __( + 'Choose an icon from the library, pick one from your media library, or insert a custom SVG.' + ), + noCustom: __( + 'Choose an icon from the library or pick one from your media library.' + ), + noMediaLibrary: __( + 'Choose an icon from the library or insert a custom SVG.' + ), + noCustomNoMediaLibrary: __( + 'Browse the icon library and choose one to insert.' + ), + }; + + if ( ! enableCustomIcons && ! isSVGUploadAllowed ) { + return messages.noCustomNoMediaLibrary; + } else if ( ! enableCustomIcons ) { + return messages.noCustom; + } else if ( ! isSVGUploadAllowed ) { + return messages.noMediaLibrary; + } + + return messages.default; + }; + + return ( + + + { isSVGUploadAllowed && ( + + parseUploadedMediaAndSetIcon( + media, + attributes, + setAttributes + ) + } + allowedTypes={ [ 'image/svg+xml' ] } + render={ ( { open } ) => ( + + ) } + /> + ) } + { enableCustomIcons && ( + + ) } + + + ); +} diff --git a/packages/block-library/src/icon/components/index.js b/packages/block-library/src/icon/components/index.js new file mode 100644 index 00000000000000..1233025eebf962 --- /dev/null +++ b/packages/block-library/src/icon/components/index.js @@ -0,0 +1,5 @@ +export { default as CustomInserterModal } from './custom-inserter'; +export { default as InserterModal } from './inserter'; +export { default as QuickInserterPopover } from './quick-inserter'; +export { default as IconPlaceholder } from './icon-placeholder'; +export { default as IconDropZone } from './icon-dropzone'; diff --git a/packages/block-library/src/icon/components/inserter/content-header.js b/packages/block-library/src/icon/components/inserter/content-header.js new file mode 100644 index 00000000000000..bfafa81d915a5a --- /dev/null +++ b/packages/block-library/src/icon/components/inserter/content-header.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { RangeControl } from '@wordpress/components'; + +export default function ContentHeader( props ) { + const { searchInput, shownIconsCount, iconSize, setIconSize } = props; + + return ( +
+
+ { searchInput && + sprintf( + // translators: %1$s: Number of icons returned from search, %2$s: the search input + _n( + '%1$s search result for "%2$s"', + '%1$s search results for "%2$s"', + shownIconsCount + ), + shownIconsCount, + searchInput + ) } +
+
+
+ { __( 'Preview size' ) } + setIconSize( value ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> +
+
+
+ ); +} diff --git a/packages/block-library/src/icon/components/inserter/icon-grid.js b/packages/block-library/src/icon/components/inserter/icon-grid.js new file mode 100644 index 00000000000000..4b1626dc62ebd3 --- /dev/null +++ b/packages/block-library/src/icon/components/inserter/icon-grid.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Icon, blockDefault } from '@wordpress/icons'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { parseIcon } from './../../utils'; + +export default function IconGrid( props ) { + const { shownIcons, iconSize, updateIconAtts, attributes } = props; + + const noResults = ( +
+ +

{ __( 'No results found.' ) }

+
+ ); + + const searchResults = ( +
+ { shownIcons.map( ( icon ) => { + let renderedIcon = icon.icon; + + // Icons provided by third-parties are generally strings. + if ( typeof renderedIcon === 'string' ) { + renderedIcon = parseIcon( renderedIcon ); + } + + return ( + + ); + } ) } +
+ ); + + return ( +
+ { shownIcons.length === 0 ? noResults : searchResults } +
+ ); +} diff --git a/packages/block-library/src/icon/components/inserter/index.js b/packages/block-library/src/icon/components/inserter/index.js new file mode 100644 index 00000000000000..6fd25f0cbcd7b1 --- /dev/null +++ b/packages/block-library/src/icon/components/inserter/index.js @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Modal } from '@wordpress/components'; +import { useState, useEffect, useMemo, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import getIcons from './../../icons'; +import { flattenIconsArray, getIconTypes } from './../../utils'; +import ContentHeader from './content-header'; +import IconGrid from './icon-grid'; +import Sidebar from './sidebar'; +export default function InserterModal( props ) { + const { isInserterOpen, setInserterOpen, attributes, setAttributes } = + props; + const iconsByType = getIcons(); + const iconTypes = getIconTypes( iconsByType ); + + // Get the default type, and if there is none, get the first type. + const defaultType = useMemo( () => { + const defaultTypes = iconTypes.filter( ( type ) => type.isDefault ); + return defaultTypes.length !== 0 ? defaultTypes : [ iconTypes[ 0 ] ]; + }, [ iconTypes ] ); + + const [ searchInput, setSearchInput ] = useState( '' ); + const [ currentCategory, setCurrentCategory ] = useState( + 'all__' + defaultType[ 0 ]?.type + ); + const [ iconSize, setIconSize ] = useState( () => { + const storedSettings = window.localStorage.getItem( 'icon_block' ); + return storedSettings + ? JSON.parse( storedSettings )?.preview_size || 24 + : 24; + } ); + + useEffect( () => { + const settings = JSON.parse( + window.localStorage.getItem( 'icon_block' ) || '{}' + ); + settings.preview_size = iconSize; + window.localStorage.setItem( 'icon_block', JSON.stringify( settings ) ); + }, [ iconSize ] ); + + const iconsAll = useMemo( + () => flattenIconsArray( iconsByType ), + [ iconsByType ] + ); + + // Move the filtering logic to a separate function + const getFilteredIcons = useCallback( () => { + if ( searchInput ) { + return iconsAll.filter( ( icon ) => { + const input = searchInput.toLowerCase(); + const iconName = icon.title.toLowerCase(); + + if ( iconName.includes( input ) ) { + return true; + } + + return ( + icon?.keywords?.some( ( keyword ) => + keyword.includes( input ) + ) || false + ); + } ); + } + + if ( currentCategory.startsWith( 'all__' ) ) { + const categoryType = currentCategory.replace( 'all__', '' ); + return ( + iconsByType.find( ( type ) => type.type === categoryType ) + ?.icons || [] + ); + } + + return iconsAll.filter( + ( icon ) => icon?.categories?.includes( currentCategory ) || false + ); + }, [ searchInput, currentCategory, iconsAll, iconsByType ] ); + + if ( ! isInserterOpen ) { + return null; + } + + function updateIconAtts( name, hasNoIconFill ) { + setAttributes( { + icon: '', + iconName: name, + hasNoIconFill, + } ); + setInserterOpen( false ); + } + + function onClickCategory( category ) { + setCurrentCategory( category ); + } + + return ( + setInserterOpen( false ) } + isFullScreen + > +
+ +
+ + +
+
+
+ ); +} diff --git a/packages/block-library/src/icon/components/inserter/sidebar.js b/packages/block-library/src/icon/components/inserter/sidebar.js new file mode 100644 index 00000000000000..71b5fa99667fe9 --- /dev/null +++ b/packages/block-library/src/icon/components/inserter/sidebar.js @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuGroup, MenuItem, SearchControl } from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { simplifyCategories } from '../../utils'; + +const ALL_CATEGORY_PREFIX = 'all__'; + +export default function Sidebar( props ) { + const { + iconsByType, + currentCategory, + onClickCategory, + searchInput, + setSearchInput, + } = props; + + const preparedTypes = useMemo( () => { + return iconsByType.map( ( type ) => { + const title = type?.title ?? type.type; + const categoriesFull = type?.categories ?? []; + const categories = simplifyCategories( categoriesFull ); + const allCategory = `${ ALL_CATEGORY_PREFIX }${ type.type }`; + const iconsOfType = type?.icons ?? []; + + // Sort alphabetically and then add the "all" category. + if ( ! categories.includes( allCategory ) ) { + categories.sort().unshift( allCategory ); + categoriesFull.unshift( { + name: allCategory, + title: __( 'All' ), + } ); + } + + return { + type: type.type, + title, + categoriesFull, + categories, + count: iconsOfType.length, + }; + } ); + }, [ iconsByType ] ); + + function renderIconTypeCategories( type ) { + return ( + + { type.categories.map( ( category ) => { + const isActive = currentCategory + ? category === currentCategory + : category === `${ ALL_CATEGORY_PREFIX }${ type.type }`; + + const categoryIcons = + iconsByType + .find( ( t ) => t.type === type.type ) + ?.icons.filter( ( icon ) => { + const iconCats = icon?.categories ?? []; + return iconCats.includes( category ); + } ) ?? []; + + const categoryTitle = + type.categoriesFull.find( ( c ) => c.name === category ) + ?.title ?? category; + + return ( + onClickCategory( category ) } + isPressed={ isActive } + > + { categoryTitle } + + { category === + `${ ALL_CATEGORY_PREFIX }${ type.type }` + ? type.count + : categoryIcons.length } + + + ); + } ) } + + ); + } + + return ( +
+
+ +
+ { preparedTypes.map( ( type ) => + renderIconTypeCategories( type ) + ) } +
+ ); +} diff --git a/packages/block-library/src/icon/components/quick-inserter/index.js b/packages/block-library/src/icon/components/quick-inserter/index.js new file mode 100644 index 00000000000000..c9149ad4f55a43 --- /dev/null +++ b/packages/block-library/src/icon/components/quick-inserter/index.js @@ -0,0 +1,181 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Popover, SearchControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { Icon, blockDefault } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import getIcons from './../../icons'; +import { flattenIconsArray, parseIcon } from './../../utils'; + +export default function QuickInserterPopover( props ) { + const [ searchInput, setSearchInput ] = useState( '' ); + const { + setInserterOpen, + isQuickInserterOpen, + setQuickInserterOpen, + setAttributes, + } = props; + + if ( ! isQuickInserterOpen ) { + return null; + } + + function updateIconAtts( name, hasNoIconFill ) { + setAttributes( { + icon: '', + iconName: name, + hasNoIconFill, + } ); + setInserterOpen( false ); + } + + const iconsByType = getIcons(); + const iconsAll = flattenIconsArray( iconsByType ); + + // Get the icons of the default type, if there is one. Otherwise, just pull + // from the first icon type. + const iconsOfDefaultType = + iconsByType.filter( ( t ) => t.isDefault )[ 0 ]?.icons ?? iconsAll; + + let shownIcons = []; + + if ( searchInput ) { + shownIcons = iconsAll.filter( ( icon ) => { + const input = searchInput.toLowerCase(); + const iconName = icon.title.toLowerCase(); + + // First check if the name matches. + if ( iconName.includes( input ) ) { + return true; + } + + // Then check if any keywords match. + if ( icon?.keywords && icon?.keywords.length !== 0 ) { + const keywordMatches = icon.keywords.filter( ( keyword ) => + keyword.includes( input ) + ); + + return keywordMatches.length > 0; + } + + return false; + } ); + } + + if ( ! searchInput ) { + // See if there is a default icon(s) set. + const defaultIcons = + iconsOfDefaultType.filter( ( i ) => i.isDefault ) ?? []; + + // Get the rest of the icons in the type excluding the default ones. + const nonDefaultIcons = + iconsOfDefaultType.filter( ( i ) => ! i.isDefault ) ?? []; + + // First show the default icons, then the rest. + shownIcons = shownIcons.concat( defaultIcons, nonDefaultIcons ); + } + + // Only want to display 6 icons. + shownIcons = shownIcons.slice( 0, 6 ); + + const searchResults = ( +
+
+ { shownIcons.map( ( icon ) => { + let renderedIcon = icon.icon; + + if ( typeof renderedIcon === 'string' ) { + renderedIcon = parseIcon( renderedIcon ); + } + + return ( + + ); + } ) } +
+
+ ); + + const noResults = ( +
+ +

{ __( 'No results found.' ) }

+
+ ); + + return ( + setQuickInserterOpen( false ) } + position="bottom right" + offset={ 12 } + > +
+ setSearchInput( value ) } + __nextHasNoMarginBottom + /> +
+ { [ + shownIcons.length === 0 && noResults, + shownIcons.length > 0 && searchResults, + ] } +
+ +
+
+ ); +} diff --git a/packages/block-library/src/icon/edit.js b/packages/block-library/src/icon/edit.js new file mode 100644 index 00000000000000..8ab78172a724d3 --- /dev/null +++ b/packages/block-library/src/icon/edit.js @@ -0,0 +1,393 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Dropdown, + DropdownMenu, + ExternalLink, + MenuGroup, + MenuItem, + NavigableMenu, + TextControl, + ToolbarButton, + ToolbarGroup, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { + BlockControls, + InspectorControls, + MediaUpload, + useBlockProps, + useBlockEditingMode, + __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, +} from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { DOWN } from '@wordpress/keycodes'; +import { code, media as mediaIcon } from '@wordpress/icons'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { + CustomInserterModal, + IconDropZone, + IconPlaceholder, + InserterModal, +} from './components'; +import { + flattenIconsArray, + parseIcon, + parseUploadedMediaAndSetIcon, +} from './utils'; +import { bolt as defaultIcon } from './icons/bolt'; +import getIcons from './icons'; +import { useToolsPanelDropdownMenuProps } from './utils/hooks'; + +/** + * The edit function for the Icon Block. + * + * @param {Object} props All props passed to this function. + */ +export function Edit( props ) { + const { attributes, setAttributes } = props; + const { icon, iconName, label, title } = attributes; + + // Allowed types for the current user. + const { allowedMimeTypes, mediaUpload } = useSelect( ( select ) => { + // Disabling this rule for the following line so as to not couple the block editor package to block-library. + // eslint-disable-next-line @wordpress/data-no-store-string-literals + const { getSettings } = select( 'core/block-editor' ); + + return { + allowedMimeTypes: getSettings().allowedMimeTypes, + mediaUpload: getSettings().mediaUpload, + }; + }, [] ); + + const isSVGUploadAllowed = allowedMimeTypes + ? Object.values( allowedMimeTypes ).includes( 'image/svg+xml' ) + : false; + + const [ isInserterOpen, setInserterOpen ] = useState( false ); + const [ isQuickInserterOpen, setQuickInserterOpen ] = useState( false ); + const [ isCustomInserterOpen, setCustomInserterOpen ] = useState( false ); + + // Allow users to disable custom SVG icons. + const enableCustomIcons = applyFilters( + 'iconBlock.enableCustomIcons', + true + ); + + const isContentOnlyMode = useBlockEditingMode() === 'contentOnly'; + + const iconsAll = flattenIconsArray( getIcons() ); + const namedIcon = iconsAll.filter( ( i ) => i.name === iconName ); + const customIcon = defaultIcon; + let printedIcon = ''; + if ( icon && namedIcon.length === 0 ) { + printedIcon = parseIcon( icon ); + + if ( + customIcon.props && + Object.keys( customIcon?.props ).length === 0 + ) { + printedIcon = defaultIcon; + } + } else { + // Icon chosen from library. + if ( icon.length === 0 && namedIcon.length > 0 ) { + printedIcon = namedIcon[ 0 ]?.icon; + } else { + printedIcon = icon; + } + + // Icons provided by third-parties are generally strings. + if ( typeof printedIcon === 'string' ) { + printedIcon = parseIcon( printedIcon ); + } + } + + function resetAll() { + setAttributes( { + label: undefined, + } ); + } + + const openOnArrowDown = ( event ) => { + if ( event.keyCode === DOWN ) { + event.preventDefault(); + event.target.click(); + } + }; + + const replaceText = icon || iconName ? __( 'Replace' ) : __( 'Add icon' ); + const customIconText = + icon || iconName + ? __( 'Add/edit custom icon' ) + : __( 'Add custom icon' ); + + const replaceDropdown = ( + ( + + { replaceText } + + ) } + renderContent={ ( { onClose } ) => ( + + + { + setInserterOpen( true ); + onClose( true ); + } } + icon={ defaultIcon } + > + { __( 'Browse Icon Library' ) } + + { isSVGUploadAllowed && ( + { + parseUploadedMediaAndSetIcon( + media, + attributes, + setAttributes + ); + onClose( true ); + } } + allowedTypes={ [ 'image/svg+xml' ] } + render={ ( { open } ) => ( + + { __( 'Open Media Library' ) } + + ) } + /> + ) } + { enableCustomIcons && ( + { + setCustomInserterOpen( true ); + onClose( true ); + } } + icon={ code } + > + { customIconText } + + ) } + + { ( icon || iconName ) && ( + + { + setAttributes( { + icon: undefined, + iconName: undefined, + } ); + onClose( true ); + } } + > + { __( 'Reset' ) } + + + ) } + + ) } + /> + ); + + const blockControls = ( + <> + { ( icon || iconName ) && ( + + + + ) } + + <> + { enableCustomIcons || isSVGUploadAllowed ? ( + replaceDropdown + ) : ( + { + setInserterOpen( true ); + } } + > + { replaceText } + + ) } + + + { isContentOnlyMode && ( icon || iconName ) && ( + // Add some extra controls for content attributes when content only mode is active. + // With content only mode active, the inspector is hidden, so users need another way + // to edit these attributes. + + + + { () => ( + + setAttributes( { label: value } ) + } + help={ __( + 'Briefly describe the icon to help screen reader users.' + ) } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + ) } + + + + ) } + + ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + const inspectorControls = ( icon || iconName ) && ( + <> + + + !! label } + onDeselect={ () => + setAttributes( { label: undefined } ) + } + > + + setAttributes( { label: value } ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + + + + setAttributes( { title: value } ) } + help={ + <> + { __( + 'Describe the role of this icon on the page.' + ) } + + { __( + 'Note: many devices and browsers do not display this text' + ) } + + + } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + ); + + const borderProps = getBorderClassesAndStyles( attributes ); + + const iconMarkup = ( + <> + { ! icon && ! iconName ? ( + + ) : ( + <>{ printedIcon } + ) } + + ); + + return ( + <> + { blockControls } + { inspectorControls } +
+ { iconMarkup } + +
+ + { enableCustomIcons && ( + + ) } + + ); +} + +export default Edit; diff --git a/packages/block-library/src/icon/editor.scss b/packages/block-library/src/icon/editor.scss new file mode 100644 index 00000000000000..1c9d1a0fab379f --- /dev/null +++ b/packages/block-library/src/icon/editor.scss @@ -0,0 +1,625 @@ +/** + * Editor only styles for the Icon Block. + */ + +// Editor specific styles for the block itself. +.wp-block-outermost-icon-block { + + // Dropzone styles. + .components-drop-zone__content { + container-type: size; + + .components-drop-zone__content-icon { + width: 24px; + margin-bottom: 10px; + } + + // Only show the icon if the dropzone is too small. + @container (max-width: 150px) { + .components-drop-zone__content-icon { + margin-bottom: 0; + } + + .components-drop-zone__content-text { + display: none; + } + } + + @container (max-height: 120px) { + .components-drop-zone__content-icon { + margin-bottom: 0; + } + + .components-drop-zone__content-text { + display: none; + } + } + } + + // Placeholder styles. + .components-placeholder { + + // Needed to match the Core styling while supporting icon rotation. + svg.components-placeholder__illustration { + transform: translate(-50%, -50%); + } + + .components-placeholder__label { + svg { + max-width: 24px; + } + } + } + + &.is-selected { + + // Placeholder styles. + .components-placeholder { + background-color: #fff; + border: none; + border-radius: 2px; + box-shadow: inset 0 0 0 1px #1e1e1e; + color: #1e1e1e; + filter: none; + + &::before { + opacity: 0; + } + + .components-placeholder__illustration { + display: none; + } + } + } +} + +.wp-block-outermost-icon-inserter__quick-inserter { + .icons-list { + .icons-list__item { + width: 33.33%; + + // The quick inserter in core does not have border hover effects. + &:hover { + border-color: transparent; + } + } + } + + // Temporary fix until updated in core. + .block-editor-inserter__no-results { + margin-top: 0; + } + + // Fix minor gap between search field and icons. + .block-editor-inserter__search { + & > .components-base-control__field { + margin-bottom: 0; + } + } +} + +// Style for the icon library modal. +.wp-block-outermost-icon-inserter__modal { + .components-modal__content { + flex: 1 1 0%; + overflow: auto; + padding: 0; + + &::before { + margin-bottom: 0; + } + } + + .icon-inserter { + align-items: stretch; + display: flex; + height: 100%; + } + + .icon-inserter__sidebar { + display: none; + flex-direction: column; + flex-shrink: 0; + overflow-y: scroll; + width: 280px; + padding: 24px 32px 32px; + + @media (min-width: 900px) { + display: flex; + } + + &__search { + margin-bottom: 16px; + + .components-base-control__field { + margin-bottom: 0; + } + } + + &__category-type { + // Override core component styles. + border: none; + + .components-menu-group__label { + margin: 0; + padding: 16px 12px 16px; + } + + .components-menu-item__item { + align-items: flex-start; + display: inline-flex; + justify-content: space-between; + white-space: normal; + width: 100%; + text-align: left; + text-transform: capitalize; + + & > span { + opacity: 0.5; + } + } + } + } + + .icon-inserter__content { + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: auto; + padding-top: 24px; + padding-right: 32px; + width: 100%; + + @media (min-width: 900px) { + width: calc(100% - 281px); + } + + .icon-inserter__content-header { + align-items: center; + display: flex; + height: 40px; + justify-content: space-between; + margin-bottom: 24px; + + .search-results { + color: #757575; + } + + .icon-controls__size { + display: flex; + align-items: center; + margin-right: 6px; + + & > span { + font-size: 11px; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + display: block; + margin-right: 18px; + } + + .components-range-control { + width: 116px; + + .components-base-control__field { + margin-bottom: 0; + } + } + } + } + + .icons-list { + padding: 4px 4px 72px 4px; + margin: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + + @media (min-width: 600px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + } + + @media (min-width: 900px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + } + + @media (min-width: 1100px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + } + + @media (min-width: 1450px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + } + + @media (min-width: 1600px) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + } + + .icons-list__item { + &.is-active { + background-color: var(--wp-admin-theme-color); + color: rgb(255 255 255 / 80%); + + &:hover { + color: #fff !important; + } + + svg { + fill: #fff; + } + + &.has-no-icon-fill { + svg { + color: #fff; + fill: none; + } + } + } + } + } + + .block-editor-inserter__no-results { + height: calc(100% - 56px); + margin-top: 0; + display: flex; + flex-direction: column; + justify-content: center; + + svg { + margin: 0 auto; + } + } + } +} + +// Styles for the custom SVG inserter modal. +.wp-block-outermost-icon-custom-inserter__modal { + .components-modal__content { + flex: 1 1 0%; + overflow: auto; + padding: 0; + + & > div:nth-child(2) { + height: 100%; + } + + &::before { + margin-bottom: 0; + } + } + + .icon-custom-inserter { + align-items: stretch; + display: flex; + flex-direction: column; + gap: 32px; + height: 100%; + padding: 0 32px 32px; + + @media (min-width: 900px) { + flex-direction: row; + } + } + + .icon-custom-inserter__content { + min-height: 400px; + + @media (min-width: 900px) { + width: calc(100% - 400px); + } + + .components-base-control, + .components-base-control__field, + .components-textarea-control__input { + height: 100%; + } + + textarea { + resize: none; + } + } + + .icon-custom-inserter__sidebar { + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + + @media (min-width: 900px) { + width: 400px; + } + + .icon-preview__window { + border: 1px solid #ddd; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + min-height: 250px; + max-height: 400px; + overflow: scroll; + + &.is-default { + svg { + opacity: 0.3; + } + } + + svg { + max-width: 100%; + } + } + + .icon-controls { + display: flex; + justify-content: center; + padding: 12px 24px; + } + + .icon-controls__size { + display: flex; + align-items: center; + + & > span { + font-size: 11px; + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + display: block; + margin-right: 18px; + } + + .components-range-control { + width: 116px; + + .components-base-control__field { + margin-bottom: 0; + } + } + } + + .components-notice { + margin: 12px 0 24px; + + // Needed to fix color error in Notice component. + &.is-error { + background-color: #f8ebea; + } + } + + .icon-insert-buttons { + display: flex; + justify-content: flex-end; + gap: 16px; + } + } +} + +// Styles for the block's toolbar controls. +.block-editor-block-toolbar { + .outermost-icon-block__rotate-button-90 { + svg { + transform: rotate(90deg); + } + } + + .outermost-icon-block__rotate-button-180 { + svg { + transform: rotate(180deg); + } + } + + .outermost-icon-block__rotate-button-270 { + svg { + transform: rotate(270deg); + } + } +} + +// Styles for the block's inspector controls. +.block-editor-block-inspector { + + // Styling for the options panel used for the block settings. + .outermost-icon-block__main-settings { + &.options-panel { + border-top: 1px solid #ddd; + display: grid; + gap: 16px; + margin-top: -1px; + padding: 16px; + + .options-panel-header { + align-items: center; + display: flex; + flex-direction: row; + gap: calc(8px); + grid-column: 1 / -1; + justify-content: space-between; + width: 100%; + -webkit-box-align: center; + -webkit-box-pack: justify; + + h2 { + font-weight: 500; + margin: 0; + } + + .options-panel-header__dropdown-menus { + line-height: 0; + margin: -4px 0; + + .components-button { + padding: 0; + min-width: 24px; + } + } + } + + .options-panel-container { + display: grid; + grid-gap: 16px; + + .components-base-control { + margin: 0; + } + + .components-base-control__help { + margin-bottom: 0; + } + } + } + } + + // Styles for color settings. + .color-block-support-panel, + .outermost-icon-block__color-settings { + .outermost-icon-block__color-settings__help { + font-size: 12px; + font-style: normal; + color: rgb(117, 117, 117); + grid-column: 1 / -1; + margin-top: 8px; + margin-bottom: 24px; + + &:first-child { + margin-top: 0; + } + } + + .outermost-icon-block__color-settings__apply-fill { + grid-column: 1 / -1; + margin: 0; + } + + .components-base-control__help { + margin-bottom: 0; + } + + .block-editor-contrast-checker { + margin-top: 24px; + + .components-notice__content { + margin-right: 0; + } + } + } + + // Styles for advanced settings. + .outermost-icon-block__title-control { + .components-external-link { + display: block; + margin-top: 8px; + } + } +} + +// Styles for the icon list grid +.wp-block-outermost-icon-inserter__modal, +.wp-block-outermost-icon-inserter__quick-inserter { + .icons-list { + display: flex; + flex-wrap: wrap; + + .icons-list__item { + align-items: stretch; + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + color: #1e1e1e; + cursor: pointer; + display: flex; + flex-direction: column; + font-size: 13px; + height: auto; + justify-content: center; + padding: 8px; + position: relative; + transition: all 0.05s ease-in-out; + word-break: break-word; + + &:hover { + color: var(--wp-admin-theme-color) !important; + } + + &.has-no-icon-fill { + svg { + fill: none; + } + } + + .icons-list__item-icon { + border-radius: 2px; + color: #1e1e1e; + padding: 12px; + transition: all 0.05s ease-in-out; + } + + .icons-list__item-title { + font-size: 12px; + padding: 4px 2px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } +} + +// Styles for the options dropdown popover. +.options-panel__option-popover { + + // Same size as core popovers. + .components-popover__content { + min-width: 240px; + } + + .components-menu-item__button { + &[aria-disabled="true"]:not(.is-tertiary) { + color: #757575; + opacity: 1; + + // Fix style bug in WordPress 6.1 and lower. + &:focus { + box-shadow: none; + outline: none; + } + } + + &.has-reset { + .components-menu-item__item { + min-width: 100%; + } + } + + .components-menu-item__item { + .menu-item-reset { + color: var(--wp-admin-theme-color-darker-10); + font-size: 11px; + font-weight: 500; + line-height: 1.4; + margin-left: auto; + text-transform: uppercase; + } + } + } +} + +// Minor style fix for the block toolbar. Not perfect, but better than nothing. +.wp-block-outermost-icon-block__toolbar { + padding-left: 0; +} + +.wp-block-outermost-icon-block__toolbar_content { + width: 250px; +} + +.wp-block-outermost-icon-block__link-popover { + + .block-editor-link-control__field { + margin: 8px; + } + + .block-editor-link-control__search-item { + padding: 8px; + } +} diff --git a/packages/block-library/src/icon/icons/bolt.js b/packages/block-library/src/icon/icons/bolt.js new file mode 100644 index 00000000000000..f327add7b8f2e8 --- /dev/null +++ b/packages/block-library/src/icon/icons/bolt.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export const bolt = ( + + + +); diff --git a/packages/block-library/src/icon/icons/index.js b/packages/block-library/src/icon/icons/index.js new file mode 100644 index 00000000000000..d531660a0f6867 --- /dev/null +++ b/packages/block-library/src/icon/icons/index.js @@ -0,0 +1,464 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + atSymbol, + audio, + box, + brush, + calendar, + cancelCircleFilled, + capturePhoto, + captureVideo, + category, + cautionFilled, + chartBar, + check, + chevronDown, + chevronLeft, + chevronRight, + chevronUp, + chevronLeftSmall, + chevronRightSmall, + close, + closeSmall, + cloud, + cloudUpload, + code, + cog, + color, + commentContent, + commentAuthorAvatar, + desktop, + download, + external, + file, + gallery, + help, + helpFilled, + image, + info, + link, + lockOutline, + media, + megaphone, + mobile, + page, + people, + plugins, + plus, + quote, + redo, + resizeCornerNE, + rss, + search, + seen, + settings, + starEmpty, + starFilled, + store, + trash, + trendingDown, + trendingUp, + undo, + video, + wordpress, +} from '@wordpress/icons'; + +const wordpressIcons = [ + { + name: 'arrowDown', + title: __( 'Arrow Down' ), + icon: arrowDown, + categories: [ 'arrows' ], + }, + { + name: 'arrowLeft', + title: __( 'Arrow Left' ), + icon: arrowLeft, + categories: [ 'arrows' ], + }, + { + name: 'arrowRight', + title: __( 'Arrow Right' ), + icon: arrowRight, + categories: [ 'arrows' ], + }, + { + name: 'arrowUp', + title: __( 'Arrow Up' ), + icon: arrowUp, + categories: [ 'arrows' ], + }, + { + name: 'atSymbol', + title: __( 'At Symbol' ), + icon: atSymbol, + }, + { + name: 'audio', + title: __( 'Audio' ), + icon: audio, + categories: [ 'blocks' ], + }, + { + name: 'box', + title: __( 'Box' ), + icon: box, + }, + { + name: 'brush', + title: __( 'Brush' ), + icon: brush, + }, + { + name: 'calendar', + title: __( 'Calendar' ), + icon: calendar, + }, + { + name: 'cancelCircleFilled', + title: __( 'Cancel - Circle Filled' ), + icon: cancelCircleFilled, + }, + { + name: 'capturePhoto', + title: __( 'Capture Photo' ), + icon: capturePhoto, + categories: [ 'media' ], + }, + { + name: 'captureVideo', + title: __( 'Capture Video' ), + icon: captureVideo, + categories: [ 'media' ], + }, + { + name: 'category', + title: __( 'Category' ), + icon: category, + categories: [ 'admin' ], + }, + { + name: 'chartBar', + title: __( 'Chart Bar' ), + icon: chartBar, + }, + { + name: 'check', + title: __( 'Check' ), + icon: check, + }, + { + name: 'chevronDown', + title: __( 'Chevron Down' ), + icon: chevronDown, + categories: [ 'arrows' ], + }, + { + name: 'chevronLeft', + title: __( 'Chevron Left' ), + icon: chevronLeft, + categories: [ 'arrows' ], + }, + { + name: 'chevronRight', + title: __( 'Chevron Right' ), + icon: chevronRight, + categories: [ 'arrows' ], + }, + { + name: 'chevronUp', + title: __( 'Chevron Up' ), + icon: chevronUp, + categories: [ 'arrows' ], + }, + { + name: 'chevronLeftSmall', + title: __( 'Chevron Left Small' ), + icon: chevronLeftSmall, + categories: [ 'arrows' ], + }, + { + name: 'chevronRightSmall', + title: __( 'Chevron Right Small' ), + icon: chevronRightSmall, + categories: [ 'arrows' ], + }, + { + name: 'close', + title: __( 'Close' ), + icon: close, + }, + { + name: 'closeSmall', + title: __( 'Close - Small' ), + icon: closeSmall, + }, + { + name: 'cloud', + title: __( 'Cloud' ), + icon: cloud, + }, + { + name: 'cloudUpload', + title: __( 'Cloud Upload' ), + icon: cloudUpload, + categories: [ 'media' ], + }, + { + name: 'code', + title: __( 'Code' ), + icon: code, + categories: [ 'blocks' ], + }, + { + name: 'cog', + title: __( 'Cog' ), + icon: cog, + }, + { + name: 'color', + title: __( 'Color' ), + icon: color, + }, + { + name: 'commentContent', + title: __( 'Comment Content' ), + icon: commentContent, + categories: [ 'blocks' ], + }, + { + name: 'commentAuthorAvatar', + title: __( 'Comment Author Avatar' ), + icon: commentAuthorAvatar, + categories: [ 'blocks' ], + }, + { + name: 'desktop', + title: __( 'Desktop' ), + icon: desktop, + categories: [ 'design' ], + }, + { + name: 'download', + title: __( 'Download' ), + icon: download, + categories: [ 'media' ], + }, + { + name: 'external', + title: __( 'External' ), + icon: external, + }, + { + name: 'file', + title: __( 'File' ), + icon: file, + }, + { + name: 'gallery', + title: __( 'Gallery' ), + icon: gallery, + categories: [ 'blocks' ], + }, + { + name: 'help', + title: __( 'Help' ), + icon: help, + }, + { + name: 'helpFilled', + title: __( 'Help - Filled' ), + icon: helpFilled, + }, + { + name: 'image', + title: __( 'Image' ), + icon: image, + categories: [ 'blocks' ], + }, + { + name: 'info', + title: __( 'Info' ), + icon: info, + }, + { + name: 'link', + title: __( 'Link' ), + icon: link, + }, + { + name: 'lockOutline', + title: __( 'Lock - Outline' ), + icon: lockOutline, + }, + { + name: 'media', + title: __( 'Media' ), + icon: media, + categories: [ 'blocks' ], + }, + { + name: 'megaphone', + title: __( 'Megaphone' ), + icon: megaphone, + }, + { + name: 'mobile', + title: __( 'Mobile' ), + icon: mobile, + categories: [ 'design' ], + }, + { + name: 'page', + title: __( 'Page' ), + icon: page, + categories: [ 'blocks' ], + }, + { + name: 'people', + title: __( 'People' ), + icon: people, + }, + { + name: 'plugins', + title: __( 'Plugins' ), + icon: plugins, + }, + { + name: 'plus', + title: __( 'Plus' ), + icon: plus, + }, + { + name: 'quote', + title: __( 'Quote' ), + icon: quote, + categories: [ 'blocks' ], + }, + { + name: 'redo', + title: __( 'Redo' ), + icon: redo, + }, + { + name: 'resizeCornerNE', + title: __( 'Resize Corner - Northeast' ), + icon: resizeCornerNE, + }, + { + name: 'rss', + title: __( 'RSS' ), + icon: rss, + categories: [ 'blocks' ], + }, + { + name: 'search', + title: __( 'Search' ), + icon: search, + categories: [ 'blocks' ], + }, + { + name: 'seen', + title: __( 'Seen' ), + icon: seen, + }, + { + name: 'settings', + title: __( 'Settings' ), + icon: settings, + }, + { + name: 'starEmpty', + title: __( 'Star - Empty' ), + icon: starEmpty, + }, + { + name: 'starFilled', + title: __( 'Star - Filled' ), + icon: starFilled, + }, + { + name: 'store', + title: __( 'Store' ), + icon: store, + }, + { + name: 'trash', + title: __( 'Trash' ), + icon: trash, + }, + { + name: 'trendingDown', + title: __( 'Trending Down' ), + icon: trendingDown, + }, + { + name: 'trendingUp', + title: __( 'Trending Up' ), + icon: trendingUp, + }, + { + name: 'undo', + title: __( 'Undo' ), + icon: undo, + }, + { + name: 'video', + title: __( 'Video' ), + icon: video, + categories: [ 'blocks' ], + }, + { + name: 'cautionFilled', + title: __( 'Warning' ), + icon: cautionFilled, + }, + { + name: 'wordpress', + title: __( 'WordPress' ), + icon: wordpress, + }, +]; + +const icons = [ + { + isDefault: false, + type: 'wordpress', + title: __( 'WordPress' ), + icons: [].concat( wordpressIcons ), + categories: [ + { + name: 'arrows', + title: __( 'Arrows' ), + }, + { + name: 'blocks', + title: __( 'Blocks' ), + }, + { + name: 'admin', + title: __( 'Admin' ), + }, + { + name: 'design', + title: __( 'Design' ), + }, + { + name: 'media', + title: __( 'Media' ), + }, + ], + }, +]; + +export default function getIcons() { + return applyFilters( 'blocks.registerBlockType.icons', icons ); +} diff --git a/packages/block-library/src/icon/index.js b/packages/block-library/src/icon/index.js new file mode 100644 index 00000000000000..cda21d65941124 --- /dev/null +++ b/packages/block-library/src/icon/index.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import edit from './edit'; +import metadata from './block.json'; +import save from './save'; +import { bolt as icon } from './icons/bolt'; + +const { name } = metadata; +export { metadata, name }; +export const settings = { + icon, + example: { + attributes: { + icon: '', + iconColorValue: '#ffffff', + iconBackgroundColorValue: '#0F172A', + itemsJustification: 'center', + width: '60px', + style: { + border: { + radius: '50px', + }, + spacing: { + padding: { + top: '10px', + right: '10px', + bottom: '10px', + left: '10px', + }, + }, + }, + }, + }, + edit, + save, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/icon/save.js b/packages/block-library/src/icon/save.js new file mode 100644 index 00000000000000..2d283bba507668 --- /dev/null +++ b/packages/block-library/src/icon/save.js @@ -0,0 +1,84 @@ +/** + * WordPress dependencies + */ +import { + useBlockProps, + __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import getIcons from './icons'; +import { flattenIconsArray, parseIcon } from './utils'; + +/** + * The save function for the Icon Block. + * + * @param {Object} props All props passed to this function. + */ +export default function save( props ) { + const { icon, iconName, label, title } = props.attributes; + + // If there is no icon and no iconName, don't save anything. + if ( ! icon && ! iconName ) { + return null; + } + + const iconsAll = flattenIconsArray( getIcons() ); + const namedIcon = iconsAll.filter( ( i ) => i.name === iconName ); + let printedIcon = ''; + + // If there is an icon and the name is empty, then it's a custom icon. + if ( icon && namedIcon.length === 0 ) { + // Custom icons are strings and need to be parsed. + printedIcon = parseIcon( icon ); + + if ( + printedIcon.props && + Object.keys( printedIcon?.props ).length === 0 + ) { + printedIcon = ''; + } + } else { + // Icon chosen from library. + if ( icon.length === 0 && namedIcon.length > 0 ) { + printedIcon = namedIcon[ 0 ]?.icon; + } else { + printedIcon = icon; + } + + // Icons provided by third-parties are generally strings. + if ( typeof printedIcon === 'string' ) { + printedIcon = parseIcon( printedIcon ); + } + } + + // If there is no valid SVG icon, don't save anything. + if ( ! printedIcon ) { + return null; + } + + // If a label is set, add as aria-label. Will overwrite any aria-label in + // custom icons. + if ( label ) { + printedIcon = { + ...printedIcon, + props: { ...printedIcon.props, 'aria-label': label }, + }; + } + + const borderProps = getBorderClassesAndStyles( props.attributes ); + + return ( +
+ { printedIcon } +
+ ); +} diff --git a/packages/block-library/src/icon/style.scss b/packages/block-library/src/icon/style.scss new file mode 100644 index 00000000000000..f641b48fa6ffed --- /dev/null +++ b/packages/block-library/src/icon/style.scss @@ -0,0 +1,17 @@ +/** + * Editor and frontend styles for the Icon Block. + */ + +/* Icon Block styles. */ +.wp-block-icon { + display: flex; + line-height: 0; + + svg { + width: 100%; + height: 100%; + transition: transform 0.1s ease-in-out; + transform: rotate(var(--outermost--icon-block--transform-rotate, 0deg)) scaleX(var(--outermost--icon-block--transform-scale-x, 1)) scaleY(var(--outermost--icon-block--transform-scale-y, 1)); + } + +} diff --git a/packages/block-library/src/icon/utils/display-messages.js b/packages/block-library/src/icon/utils/display-messages.js new file mode 100644 index 00000000000000..59587215c59cbe --- /dev/null +++ b/packages/block-library/src/icon/utils/display-messages.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { dispatch } from '@wordpress/data'; + +/** + * Display a snackbar message if there is an error when inserting an icon + * from the Media Library. + * + * @param {string} messageType The type of message to display. + */ +export function displayMessages( messageType ) { + const messages = { + fileTypeUploadError: __( + 'An error occurred while uploading. The file does not appear to be an SVG.' + ), + fileTypeSelectError: __( + 'An error occurred while inserting the icon. The media selected is not an SVG.' + ), + fileTypeError: __( + 'An error occurred while inserting the icon. Check that the file is valid SVG.' + ), + }; + + // Disabling as to not make core/notices a dependency of this package. + // eslint-disable-next-line @wordpress/data-no-store-string-literals + dispatch( 'core/notices' ).createNotice( + 'snackbar-notice', + messageType ? messages[ messageType ] : messages.fileType, + { type: 'snackbar', isDismissible: true } + ); +} diff --git a/packages/block-library/src/icon/utils/hooks.js b/packages/block-library/src/icon/utils/hooks.js new file mode 100644 index 00000000000000..3b1c57d45be46b --- /dev/null +++ b/packages/block-library/src/icon/utils/hooks.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { useViewportMatch } from '@wordpress/compose'; + +// A utility function that sets the popover props. +export function useToolsPanelDropdownMenuProps() { + const isMobile = useViewportMatch( 'medium', '<' ); + return ! isMobile + ? { + popoverProps: { + placement: 'left-start', + // For non-mobile, inner sidebar width (248px) - button width (24px) - border (1px) + padding (16px) + spacing (20px) + offset: 259, + }, + } + : {}; +} diff --git a/packages/block-library/src/icon/utils/icon-functions.js b/packages/block-library/src/icon/utils/icon-functions.js new file mode 100644 index 00000000000000..acee9310b77fc2 --- /dev/null +++ b/packages/block-library/src/icon/utils/icon-functions.js @@ -0,0 +1,77 @@ +// Get all icon types. +export function getIconTypes( icons ) { + const iconTypes = []; + + icons.forEach( ( type ) => { + const iconType = type?.type; + const typeTitle = type?.title ?? type?.type; + const isDefault = type?.isDefault ?? false; + + if ( iconType.length > 0 ) { + iconTypes.push( { + type: iconType, + title: typeTitle, + isDefault, + } ); + } + } ); + + return iconTypes; +} + +// Extracts all icons from all types and places them in a single array. +export function flattenIconsArray( icons ) { + let allIcons = []; + + icons.forEach( ( type ) => { + const iconType = type?.type; + const iconsOfType = type?.icons; + + if ( iconsOfType.length > 0 ) { + // Append the type to the icon name and add the type parameter. + iconsOfType.forEach( ( icon ) => { + // This temporarily fixes a recursion error. + if ( ! icon.name.includes( iconType + '-' ) ) { + icon.name = iconType + '-' + icon.name; + } + icon.type = iconType; + } ); + + // Sort the icons alphabetically. + iconsOfType.sort( function ( a, b ) { + return a.name.localeCompare( b.name ); + } ); + + allIcons = allIcons.concat( iconsOfType ); + } + } ); + + return allIcons; +} + +// Simplify the categories into a single array. +export function simplifyCategories( categories ) { + const simplifiedCategories = []; + + categories.forEach( ( category ) => { + if ( category?.name ) { + simplifiedCategories.push( category.name ); + } + } ); + + // Make sure the array is alphabetized with any 'all_' categories first. + simplifiedCategories.sort( ( a, b ) => { + // Prioritize any string starting with 'all__'. + if ( a.startsWith( 'all__' ) ) { + return -1; + } + if ( b.startsWith( 'all__' ) ) { + return 1; + } + + return a.localeCompare( b ); // Otherwise, sort alphabetically. + } ); + + // Return an alphabetized array of categories. + return simplifiedCategories; +} diff --git a/packages/block-library/src/icon/utils/index.js b/packages/block-library/src/icon/utils/index.js new file mode 100644 index 00000000000000..50946041fbee03 --- /dev/null +++ b/packages/block-library/src/icon/utils/index.js @@ -0,0 +1,11 @@ +export { + getIconTypes, + flattenIconsArray, + simplifyCategories, +} from './icon-functions'; +export { parseIcon } from './parse-icon'; +export { + parseUploadedMediaAndSetIcon, + parseDroppedMediaAndSetIcon, +} from './parse-media'; +export { displayMessages } from './display-messages'; diff --git a/packages/block-library/src/icon/utils/parse-icon.js b/packages/block-library/src/icon/utils/parse-icon.js new file mode 100644 index 00000000000000..07c0cff95135f5 --- /dev/null +++ b/packages/block-library/src/icon/utils/parse-icon.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import parse, { attributesToProps, domToReact } from 'html-react-parser'; + +/** + * Parse the icon sting into a React object. + * + * @param {string} icon The HTML icon. + * @return {Object} The icons as a React object. + */ +export function parseIcon( icon ) { + const newIcon = icon.trim(); + + const parseOptions = { + trim: true, + replace: ( { attribs, children, name, parent, type } ) => { + // Allow text but only within text elements. + if ( type === 'text' && parent && parent.name === 'text' ) { + return; + } + + if ( + ( type !== 'tag' && type !== 'style' ) || // Allow svg and style tags. + ( ! parent && name !== 'svg' ) || // The only root-level element can be an svg. + ! name + ) { + return <>; + } + + const Tag = `${ name }`; + + // Handle style tags differently. + if ( type === 'style' && name === 'style' && children ) { + // Make sure it's not an empty style elements. + if ( children[ 0 ]?.data ) { + return ( + + { children[ 0 ].data } + + ); + } + return <>; + } + + // Hyphens or colons in attribute names are lost in the default + // process of html-react-parser. Spreading the attribs object as + // props avoids the loss. Style does need to be handled separately. + return ( + + { domToReact( children, parseOptions ) } + + ); + }, + }; + + return parse( newIcon, parseOptions ); +} + +/** + * Parse the style attributes separately. + * + * @param {string} stylesString All styles in a string. + * @return {Object} All styles in object form. + */ +function parseStyles( stylesString ) { + let stylesObject = {}; + + if ( typeof stylesString === 'string' ) { + stylesObject = stylesString + .split( ';' ) + .reduce( ( allStyles, style ) => { + const colonPosition = style.indexOf( ':' ); + + if ( colonPosition === -1 ) { + return allStyles; + } + + const camelCaseProperty = style + .substr( 0, colonPosition ) + .trim() + .replace( /^-ms-/, 'ms-' ) + .replace( /-./g, ( c ) => c.substr( 1 ).toUpperCase() ); + const styleValue = style.substr( colonPosition + 1 ).trim(); + + return styleValue + ? { ...allStyles, [ camelCaseProperty ]: styleValue } + : allStyles; + }, {} ); + } + + return stylesObject; +} diff --git a/packages/block-library/src/icon/utils/parse-media.js b/packages/block-library/src/icon/utils/parse-media.js new file mode 100644 index 00000000000000..cdb333c4eb6253 --- /dev/null +++ b/packages/block-library/src/icon/utils/parse-media.js @@ -0,0 +1,90 @@ +/** + * Internal dependencies + */ +import { displayMessages } from './display-messages'; + +/** + * Parse a saved SVG file in the Media Library as a string and + * set the icon attribute. + * + * @param {Object} media The media object for the selected SVG file. + * @param {Object} attributes All set block attributes. + * @param {Function} setAttributes Sets the block attributes. + */ +export function parseUploadedMediaAndSetIcon( + media, + attributes, + setAttributes +) { + const { width } = attributes; + + // TODO: Very basic file type validation, likely needs more refinement. + if ( ! media.url?.endsWith( '.svg' ) ) { + displayMessages( 'fileTypeSelect' ); + return; + } + + return fetch( media.url ) + .then( ( response ) => response.text() ) + .then( ( rawString ) => { + const svgString = sanitizeRawSVGString( rawString ); + + if ( ! svgString ) { + displayMessages( 'fileTypeError' ); + return; + } + + setAttributes( { + icon: svgString, + iconName: '', + width: width ? width : media?.width, + } ); + } ) + .catch( () => displayMessages( 'fileTypeError' ) ); +} + +/** + * Parse the SVG file dropped in the DropZone and set the icon if valid. + * + * @param {string} media The media object for the selected SVG file. + * @param {Function} setAttributes Sets the block attributes. + */ +export function parseDroppedMediaAndSetIcon( media, setAttributes ) { + const svgString = sanitizeRawSVGString( media ); + + if ( ! svgString ) { + displayMessages( 'fileTypeError' ); + return; + } + + setAttributes( { + icon: svgString, + iconName: '', + } ); +} + +/** + * Sanitize the raw string and make sure it's an SVG. + * + * @param {string} rawString The media object for the selected SVG file. + * @return { string } The sanitized svg string. + */ +function sanitizeRawSVGString( rawString ) { + const svgDoc = new window.DOMParser().parseFromString( + rawString, + 'image/svg+xml' + ); + let svgString = ''; + + // TODO: Very basic SVG sanitization, likely needs more refinement. + if ( + svgDoc.childNodes.length === 1 && + svgDoc.firstChild.nodeName === 'svg' + ) { + svgString = new window.XMLSerializer().serializeToString( + svgDoc.documentElement + ); + } + + return svgString; +} diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 590e3da0330882..f190d31657e7ed 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -68,6 +68,7 @@ import * as group from './group'; import * as heading from './heading'; import * as homeLink from './home-link'; import * as html from './html'; +import * as icon from './icon'; import * as image from './image'; import * as latestComments from './latest-comments'; import * as latestPosts from './latest-posts'; @@ -176,6 +177,7 @@ const getAllBlocks = () => { file, group, html, + icon, latestComments, latestPosts, mediaText, @@ -379,7 +381,6 @@ export const registerCoreBlocks = ( setUnregisteredTypeHandlerName( missing.name ); setGroupingBlockName( group.name ); }; - /** * Function to register experimental core blocks depending on editor settings. * diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 249193e1cc234a..24f17b4082cc40 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -11,6 +11,7 @@ "core/page-list", "core/spacer", "core/home-link", + "core/icon", "core/site-title", "core/site-logo", "core/navigation-submenu", diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index d94343a37b1977..ecc3b69b8dfa89 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -27,6 +27,7 @@ @import "./gallery/style.scss"; @import "./group/style.scss"; @import "./heading/style.scss"; +@import "./icon/style.scss"; @import "./image/style.scss"; @import "./latest-comments/style.scss"; @import "./latest-posts/style.scss"; diff --git a/test/integration/fixtures/blocks/core__icon.html b/test/integration/fixtures/blocks/core__icon.html new file mode 100644 index 00000000000000..c6c2c59ef044b4 --- /dev/null +++ b/test/integration/fixtures/blocks/core__icon.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__icon.json b/test/integration/fixtures/blocks/core__icon.json new file mode 100644 index 00000000000000..e6ac0ba6a62511 --- /dev/null +++ b/test/integration/fixtures/blocks/core__icon.json @@ -0,0 +1,10 @@ +[ + { + "name": "core/icon", + "isValid": true, + "attributes": { + "icon": "" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__icon.parsed.json b/test/integration/fixtures/blocks/core__icon.parsed.json new file mode 100644 index 00000000000000..e02039a280361a --- /dev/null +++ b/test/integration/fixtures/blocks/core__icon.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/icon", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__icon.serialized.html b/test/integration/fixtures/blocks/core__icon.serialized.html new file mode 100644 index 00000000000000..c6c2c59ef044b4 --- /dev/null +++ b/test/integration/fixtures/blocks/core__icon.serialized.html @@ -0,0 +1 @@ +