diff --git a/src/block-components/icon/edit.js b/src/block-components/icon/edit.js index 6584c0599d..b4c21a0ed5 100644 --- a/src/block-components/icon/edit.js +++ b/src/block-components/icon/edit.js @@ -31,6 +31,7 @@ import { import { __ } from '@wordpress/i18n' import { Fragment, useMemo } from '@wordpress/element' import { applyFilters } from '@wordpress/hooks' +import { dispatch } from '@wordpress/data' export const Edit = props => { const { @@ -92,6 +93,8 @@ export const Edit = props => { value={ attributes.icon } defaultValue={ defaultValue } onChange={ icon => { + dispatch( 'stackable/page-icons' ).removePageIcon( attributes.icon ) + if ( onChangeIcon ) { onChangeIcon( icon ) } else { diff --git a/src/block-components/icon/index.js b/src/block-components/icon/index.js index 8ff86ef799..a0ec5323f9 100644 --- a/src/block-components/icon/index.js +++ b/src/block-components/icon/index.js @@ -20,6 +20,7 @@ import { addStyles } from './style' * WordPress dependencies */ import { useBlockEditContext } from '@wordpress/block-editor' +import { dispatch, select } from '@wordpress/data' import { useMemo, useState, useRef, useEffect, renderToString, } from '@wordpress/element' @@ -57,6 +58,67 @@ const LinearGradient = ( { const NOOP = () => {} +const getSvgDef = ( href, viewBox = '0 0 24 24' ) => { + return `` +} + +const generateIconId = () => { + return Math.floor( Math.random() * new Date().getTime() ) % 100000 +} + +/** + * Extract viewBox, width, and height from SVG string without DOM manipulation + * Only checks for the specific attributes we need (case-insensitive) + * + * @param {string} svgString The SVG string to parse + * @return {Object} Object with viewBox, width, and height + */ +const extractSVGDimensions = svgString => { + if ( ! svgString || typeof svgString !== 'string' ) { + return { + viewBox: null, + width: null, + height: null, + } + } + + // Find the opening tag + const svgTagMatch = svgString.match( /]*>/i ) + if ( ! svgTagMatch ) { + return { + viewBox: null, + width: null, + height: null, + } + } + + const svgTag = svgTagMatch[ 0 ] + + // Extract only the attributes we need (case-insensitive) + // Pattern: attribute name (case-insensitive) = "value" or 'value' or value + const getAttribute = attrName => { + const regex = new RegExp( `${ attrName }\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i' ) + const match = svgTag.match( regex ) + if ( match ) { + return match[ 1 ] || match[ 2 ] || match[ 3 ] || '' + } + return null + } + + const viewBox = getAttribute( 'viewBox' ) + const widthStr = getAttribute( 'width' ) + const heightStr = getAttribute( 'height' ) + + const width = widthStr ? parseInt( widthStr, 10 ) : null + const height = heightStr ? parseInt( heightStr, 10 ) : null + + return { + viewBox, + width, + height, + } +} + export const Icon = props => { const { attrNameTemplate = '%s', @@ -122,7 +184,114 @@ export const Icon = props => { const ShapeComp = useMemo( () => getShapeSVG( getAttribute( 'backgroundShape' ) || 'blob1' ), [ getAttribute( 'backgroundShape' ) ] ) - const icon = value || getAttribute( 'icon' ) + const _icon = value || getAttribute( 'icon' ) + const currentIconRef = useRef( _icon ) + const processedIconRef = useRef( null ) + const lastIconValueRef = useRef( null ) + const [ icon, setIcon ] = useState( _icon ) + + const addPageIconCount = ( svg, id ) => { + dispatch( 'stackable/page-icons' ).addPageIcon( svg, id ) + } + + useEffect( () => { + currentIconRef.current = _icon + + // Skip if we've already processed this icon + if ( processedIconRef.current === _icon ) { + return + } + + // Check if icon exists in pageIcons Map + // The Map structure is: [SVG string (key), { id: iconId, count: number } (value)] + if ( _icon ) { + const iconStr = String( _icon ) + let originalSvg = null + let iconId = null + + // Get the current state of the store + const pageIcons = select( 'stackable/page-icons' ).getPageIcons() + + // First, check if icon already exists in the store + if ( pageIcons.has( iconStr ) ) { + // Icon exists, use the existing ID and increment count + const iconData = pageIcons.get( iconStr ) + iconId = iconData?.id || iconData + originalSvg = iconStr + addPageIconCount( iconStr, iconId ) + + // Re-check after dispatch to get the actual ID (handles race conditions) + const updatedPageIcons = select( 'stackable/page-icons' ).getPageIcons() + if ( updatedPageIcons.has( iconStr ) ) { + const iconData = updatedPageIcons.get( iconStr ) + iconId = iconData?.id || iconData || iconId + } + } else if ( iconStr && iconStr.trim().startsWith( ' { + return () => { + if ( currentIconRef.current ) { + dispatch( 'stackable/page-icons' ).removePageIcon( currentIconRef.current ) + } + } + }, [] ) + if ( ! icon ) { return null } @@ -171,6 +340,7 @@ export const Icon = props => { __deprecateUseRef={ popoverEl } onClose={ () => setIsOpen( false ) } onChange={ icon => { + dispatch( 'stackable/page-icons' ).removePageIcon( _icon ) if ( onChange === NOOP ) { updateAttributeHandler( 'icon' )( icon ) } else { diff --git a/src/components/font-awesome-icon/index.js b/src/components/font-awesome-icon/index.js index 499b4d3da6..a731038aa2 100644 --- a/src/components/font-awesome-icon/index.js +++ b/src/components/font-awesome-icon/index.js @@ -1,9 +1,7 @@ /** * External dependencies */ -import { - faGetIcon, faFetchIcon, createElementFromHTMLString, -} from '~stackable/util' +import { faGetIcon, faFetchIcon } from '~stackable/util' import { pick } from 'lodash' /** @@ -55,6 +53,7 @@ const addSVGAriaLabel = ( _svgHTML, ariaLabel = '' ) => { /** * Given an SVG markup, sets an HTML attribute to the * HTML tag. + * Optimized version using string manipulation instead of DOM operations * * @param {string} svgHTML * @param {Object} attributesToAdd @@ -63,24 +62,62 @@ const addSVGAriaLabel = ( _svgHTML, ariaLabel = '' ) => { * @return {string} modified SVG HTML */ const addSVGAttributes = ( svgHTML, attributesToAdd = {}, attributesToRemove = [] ) => { - const svgNode = createElementFromHTMLString( svgHTML ) - if ( ! svgNode ) { + if ( ! svgHTML || typeof svgHTML !== 'string' ) { return '' } - Object.keys( attributesToAdd ).forEach( key => { - svgNode.setAttribute( key, attributesToAdd[ key ] ) - } ) + // Find the opening tag (handles , , ) + const svgTagMatch = svgHTML.match( /]*>/i ) + if ( ! svgTagMatch ) { + return svgHTML + } + const svgTagStart = svgTagMatch.index + const svgTagEnd = svgTagStart + svgTagMatch[ 0 ].length + const svgTag = svgTagMatch[ 0 ] + const restOfSvg = svgHTML.substring( svgTagEnd ) + + // Extract existing attributes from the SVG tag + // Handles: key="value", key='value', key=value, and boolean attributes + const attributes = {} + // Extract the content between (the attributes part) + const attributesPart = svgTag.replace( /^$/, '' ) + if ( attributesPart ) { + // Match attribute name followed by = and value (with quotes or without) + const attrRegex = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g + let attrMatch + while ( ( attrMatch = attrRegex.exec( attributesPart ) ) !== null ) { + const key = attrMatch[ 1 ] + // Value can be in double quotes, single quotes, or unquoted + const value = attrMatch[ 2 ] || attrMatch[ 3 ] || attrMatch[ 4 ] || '' + attributes[ key ] = value + } + } + + // Remove specified attributes attributesToRemove.forEach( key => { - svgNode.removeAttribute( key ) + delete attributes[ key ] } ) - return svgNode.outerHTML + // Add or update attributes + Object.assign( attributes, attributesToAdd ) + + // Rebuild the SVG tag + const newAttributes = Object.keys( attributes ) + .map( key => { + const value = attributes[ key ] + // Escape double quotes in attribute values and wrap in double quotes + const escapedValue = String( value ).replace( /"/g, '"' ) + return `${ key }="${ escapedValue }"` + } ) + .join( ' ' ) + + const newSvgTag = newAttributes ? `` : '' + return svgHTML.substring( 0, svgTagStart ) + newSvgTag + restOfSvg } const FontAwesomeIcon = memo( props => { - const { + const { svgAttrsToAdd = { width: '32', height: '32' }, svgAttrsToRemove = [ 'id', 'data-name' ], } = props diff --git a/src/plugins/index.js b/src/plugins/index.js index 36fc884946..b75dfea50c 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -6,6 +6,7 @@ import './theme-block-size' import './design-library-button' import './layout-picker-reset' import './guided-modal-tour' +import './page-icons' // import './v2-migration-popup' // Probably 1.5yrs of checking for backward compatibility is enough. import './editor-device-preview-class' import './theme-block-style-inheritance' diff --git a/src/plugins/page-icons/index.js b/src/plugins/page-icons/index.js new file mode 100644 index 0000000000..2f21f96b49 --- /dev/null +++ b/src/plugins/page-icons/index.js @@ -0,0 +1,58 @@ +/** + * This loads the page icons in the editor. + */ + +/** + * Internal dependencies + */ +import { PageIcons } from './page-icons' + +/** + * External dependencies + */ +import { useDeviceType } from '~stackable/hooks' +import { createRoot } from '~stackable/util' + +/** WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins' +import { useEffect } from '@wordpress/element' +import { __ } from '@wordpress/i18n' +import { useSelect } from '@wordpress/data' +import domReady from '@wordpress/dom-ready' + +const pageIconsWrapper = document?.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ) + +pageIconsWrapper?.setAttribute( 'id', 'stk-page-icons' ) + +domReady( () => { + if ( pageIconsWrapper ) { + pageIconsWrapper.setAttribute( 'id', 'stk-page-icons' ) + pageIconsWrapper.setAttribute( 'style', 'display: none;' ) + createRoot( pageIconsWrapper ).render( ) + } +} ) + +const PageIconsLoader = () => { + const deviceType = useDeviceType() + const editorDom = useSelect( select => { + return select( 'stackable/editor-dom' ).getEditorDom() + } ) + + /** + * Render the page icons in the editor + */ + useEffect( () => { + const editorBody = editorDom?.closest( 'body' ) + + if ( editorBody && ! editorBody.contains( pageIconsWrapper ) ) { + editorBody.prepend( pageIconsWrapper ) + } + }, [ deviceType, editorDom ] ) + + return null +} + +registerPlugin( 'stackable-page-icons', { + render: PageIconsLoader, +} ) diff --git a/src/plugins/page-icons/page-icons.js b/src/plugins/page-icons/page-icons.js new file mode 100644 index 0000000000..bbd55d5fd2 --- /dev/null +++ b/src/plugins/page-icons/page-icons.js @@ -0,0 +1,108 @@ +import './store' +import { useSelect } from '@wordpress/data' +import { safeHTML } from '@wordpress/dom' + +/** + * Parse SVG string to extract attributes and innerHTML without DOM manipulation + * + * @param {string} svgString The SVG string to parse + * @return {Object|null} Object with attributes and innerHTML, or null if invalid + */ +const parseSVGString = svgString => { + if ( ! svgString || typeof svgString !== 'string' ) { + return null + } + + // Check if it's an SVG tag + const svgTagMatch = svgString.match( /]*>/i ) + if ( ! svgTagMatch ) { + return null + } + + const svgTagStart = svgTagMatch.index + const svgTagEnd = svgTagStart + svgTagMatch[ 0 ].length + const svgTag = svgTagMatch[ 0 ] + + // Extract innerHTML (everything between opening and closing tags) + const closingTagIndex = svgString.lastIndexOf( '' ) + if ( closingTagIndex === -1 ) { + return null + } + + //SVG sanitization + let rawInnerSVG = svgString.substring( svgTagEnd, closingTagIndex ) + + // Remove ,