From ded17878173e7d7179ab5f7d4ed5614cabc82975 Mon Sep 17 00:00:00 2001 From: Mikhaela Tapia Date: Tue, 9 Dec 2025 08:01:27 +0800 Subject: [PATCH 1/3] icon optimizations - use string manipulation instead of dom operations - use page icons --- src/block-components/icon/edit.js | 3 + src/block-components/icon/index.js | 172 +++++++++++++++++++++- src/components/font-awesome-icon/index.js | 59 ++++++-- src/plugins/index.js | 1 + src/plugins/page-icons/index.js | 58 ++++++++ src/plugins/page-icons/page-icons.js | 78 ++++++++++ src/plugins/page-icons/store.js | 69 +++++++++ src/util/icon/util.js | 8 + 8 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 src/plugins/page-icons/index.js create mode 100644 src/plugins/page-icons/page-icons.js create mode 100644 src/plugins/page-icons/store.js 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..9a5751245a --- /dev/null +++ b/src/plugins/page-icons/page-icons.js @@ -0,0 +1,78 @@ +import './store' +import { useSelect } from '@wordpress/data' + +/** + * 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 + } + + const innerHTML = svgString.substring( svgTagEnd, closingTagIndex ) + + // Extract attributes from the SVG tag + const svgAttributes = {} + 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 ] + const attrNameLower = key.toLowerCase() + // Skip width and height as symbols don't need them + if ( attrNameLower !== 'width' && attrNameLower !== 'height' ) { + // Value can be in double quotes, single quotes, or unquoted + const value = attrMatch[ 2 ] || attrMatch[ 3 ] || attrMatch[ 4 ] || '' + svgAttributes[ key ] = value + } + } + } + + return { attributes: svgAttributes, innerHTML } +} + +export const PageIcons = () => { + const pageIcons = useSelect( select => select( 'stackable/page-icons' ).getPageIcons(), [] ) || new Map() + return ( + + { Array.from( pageIcons ).map( ( [ icon, iconData ] ) => { + const iconId = iconData.id + if ( ! iconId ) { + return null + } + + const parsed = parseSVGString( icon ) + if ( ! parsed ) { + return null + } + + const { attributes: svgAttributes, innerHTML } = parsed + + return ( + + ) + } ) } + + ) +} diff --git a/src/plugins/page-icons/store.js b/src/plugins/page-icons/store.js new file mode 100644 index 0000000000..fbdfb05836 --- /dev/null +++ b/src/plugins/page-icons/store.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { register, createReduxStore } from '@wordpress/data' + +const PAGE_ICONS = new Map() + +// Include all the stored state. +const DEFAULT_STATE = PAGE_ICONS + +const STORE_ACTIONS = { + addPageIcon: ( icon, iconId ) => ( { + type: 'ADD_PAGE_ICON', + icon, + iconId, + } ), + removePageIcon: icon => ( { + type: 'REMOVE_PAGE_ICON', + icon, + } ), +} + +const STORE_SELECTORS = { + getPageIcons: pageIcons => pageIcons, +} + +const STORE_REDUCER = ( state = DEFAULT_STATE, action ) => { + switch ( action.type ) { + case 'ADD_PAGE_ICON': { + const newState = new Map( state ) + if ( state.has( action.icon ) ) { + // Keep the existing ID to prevent race conditions where multiple components + // try to add the same icon with different IDs + const existingData = state.get( action.icon ) + newState.set( action.icon, { id: existingData.id, count: existingData.count + 1 } ) + return newState + } + + newState.set( action.icon, { id: action.iconId, count: 1 } ) + return newState + } + case 'REMOVE_PAGE_ICON': { + if ( state.has( action.icon ) ) { + const newState = new Map( state ) + const count = state.get( action.icon ).count - 1 + + if ( count < 1 ) { + newState.delete( action.icon ) + return newState + } + + newState.set( action.icon, { id: state.get( action.icon ).id, count } ) + return newState + } + + return state + } + default: { + return state + } + } +} + +register( createReduxStore( 'stackable/page-icons', { + reducer: STORE_REDUCER, + actions: STORE_ACTIONS, + selectors: STORE_SELECTORS, +} ) ) + diff --git a/src/util/icon/util.js b/src/util/icon/util.js index 214202749b..465f9a5e28 100644 --- a/src/util/icon/util.js +++ b/src/util/icon/util.js @@ -30,6 +30,14 @@ export const numShapesInSvg = svgString => { * @return {*} DOM Element */ export const createElementFromHTMLString = htmlString => { + // Use DOMParser (modern browsers) when available + if ( typeof DOMParser !== 'undefined' ) { + const parser = new DOMParser() + const doc = parser.parseFromString( htmlString, 'text/html' ) + return doc.body.firstElementChild + } + + // Fallback for older browsers const parentElement = document.createElement( 'div' ) parentElement.innerHTML = htmlString From 2ab415672948821f88af4980173c3622ada88a67 Mon Sep 17 00:00:00 2001 From: Mikhaela Tapia Date: Tue, 9 Dec 2025 08:13:32 +0800 Subject: [PATCH 2/3] use safeHTML for sanitization --- src/plugins/page-icons/page-icons.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/page-icons/page-icons.js b/src/plugins/page-icons/page-icons.js index 9a5751245a..8c9ed354ea 100644 --- a/src/plugins/page-icons/page-icons.js +++ b/src/plugins/page-icons/page-icons.js @@ -1,5 +1,6 @@ import './store' import { useSelect } from '@wordpress/data' +import { safeHTML } from '@wordpress/dom' /** * Parse SVG string to extract attributes and innerHTML without DOM manipulation @@ -28,7 +29,7 @@ const parseSVGString = svgString => { return null } - const innerHTML = svgString.substring( svgTagEnd, closingTagIndex ) + const innerHTML = safeHTML( svgString.substring( svgTagEnd, closingTagIndex ) ) // Extract attributes from the SVG tag const svgAttributes = {} From 183ed1ac09975b518c17f1db7d5c2cb5f4bc605e Mon Sep 17 00:00:00 2001 From: Mikhaela Tapia Date: Tue, 9 Dec 2025 08:33:22 +0800 Subject: [PATCH 3/3] svg sanitization --- src/plugins/page-icons/page-icons.js | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/plugins/page-icons/page-icons.js b/src/plugins/page-icons/page-icons.js index 8c9ed354ea..bbd55d5fd2 100644 --- a/src/plugins/page-icons/page-icons.js +++ b/src/plugins/page-icons/page-icons.js @@ -29,7 +29,36 @@ const parseSVGString = svgString => { return null } - const innerHTML = safeHTML( svgString.substring( svgTagEnd, closingTagIndex ) ) + //SVG sanitization + let rawInnerSVG = svgString.substring( svgTagEnd, closingTagIndex ) + + // Remove ,