Skip to content

Commit 8c52e7c

Browse files
authored
fix: icon optimization (#3660)
* icon optimizations - use string manipulation instead of dom operations - use page icons * use safeHTML for sanitization * svg sanitization
1 parent 2fc19bc commit 8c52e7c

File tree

8 files changed

+466
-12
lines changed

8 files changed

+466
-12
lines changed

src/block-components/icon/edit.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { __ } from '@wordpress/i18n'
3232
import { Fragment, useMemo } from '@wordpress/element'
3333
import { applyFilters } from '@wordpress/hooks'
34+
import { dispatch } from '@wordpress/data'
3435

3536
export const Edit = props => {
3637
const {
@@ -92,6 +93,8 @@ export const Edit = props => {
9293
value={ attributes.icon }
9394
defaultValue={ defaultValue }
9495
onChange={ icon => {
96+
dispatch( 'stackable/page-icons' ).removePageIcon( attributes.icon )
97+
9598
if ( onChangeIcon ) {
9699
onChangeIcon( icon )
97100
} else {

src/block-components/icon/index.js

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { addStyles } from './style'
2020
* WordPress dependencies
2121
*/
2222
import { useBlockEditContext } from '@wordpress/block-editor'
23+
import { dispatch, select } from '@wordpress/data'
2324
import {
2425
useMemo, useState, useRef, useEffect, renderToString,
2526
} from '@wordpress/element'
@@ -57,6 +58,67 @@ const LinearGradient = ( {
5758

5859
const NOOP = () => {}
5960

61+
const getSvgDef = ( href, viewBox = '0 0 24 24' ) => {
62+
return `<svg viewBox="${ viewBox }"><use href="${ href }" xlink:href="${ href }"></use></svg>`
63+
}
64+
65+
const generateIconId = () => {
66+
return Math.floor( Math.random() * new Date().getTime() ) % 100000
67+
}
68+
69+
/**
70+
* Extract viewBox, width, and height from SVG string without DOM manipulation
71+
* Only checks for the specific attributes we need (case-insensitive)
72+
*
73+
* @param {string} svgString The SVG string to parse
74+
* @return {Object} Object with viewBox, width, and height
75+
*/
76+
const extractSVGDimensions = svgString => {
77+
if ( ! svgString || typeof svgString !== 'string' ) {
78+
return {
79+
viewBox: null,
80+
width: null,
81+
height: null,
82+
}
83+
}
84+
85+
// Find the opening <svg> tag
86+
const svgTagMatch = svgString.match( /<svg\s*[^>]*>/i )
87+
if ( ! svgTagMatch ) {
88+
return {
89+
viewBox: null,
90+
width: null,
91+
height: null,
92+
}
93+
}
94+
95+
const svgTag = svgTagMatch[ 0 ]
96+
97+
// Extract only the attributes we need (case-insensitive)
98+
// Pattern: attribute name (case-insensitive) = "value" or 'value' or value
99+
const getAttribute = attrName => {
100+
const regex = new RegExp( `${ attrName }\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i' )
101+
const match = svgTag.match( regex )
102+
if ( match ) {
103+
return match[ 1 ] || match[ 2 ] || match[ 3 ] || ''
104+
}
105+
return null
106+
}
107+
108+
const viewBox = getAttribute( 'viewBox' )
109+
const widthStr = getAttribute( 'width' )
110+
const heightStr = getAttribute( 'height' )
111+
112+
const width = widthStr ? parseInt( widthStr, 10 ) : null
113+
const height = heightStr ? parseInt( heightStr, 10 ) : null
114+
115+
return {
116+
viewBox,
117+
width,
118+
height,
119+
}
120+
}
121+
60122
export const Icon = props => {
61123
const {
62124
attrNameTemplate = '%s',
@@ -122,7 +184,114 @@ export const Icon = props => {
122184

123185
const ShapeComp = useMemo( () => getShapeSVG( getAttribute( 'backgroundShape' ) || 'blob1' ), [ getAttribute( 'backgroundShape' ) ] )
124186

125-
const icon = value || getAttribute( 'icon' )
187+
const _icon = value || getAttribute( 'icon' )
188+
const currentIconRef = useRef( _icon )
189+
const processedIconRef = useRef( null )
190+
const lastIconValueRef = useRef( null )
191+
const [ icon, setIcon ] = useState( _icon )
192+
193+
const addPageIconCount = ( svg, id ) => {
194+
dispatch( 'stackable/page-icons' ).addPageIcon( svg, id )
195+
}
196+
197+
useEffect( () => {
198+
currentIconRef.current = _icon
199+
200+
// Skip if we've already processed this icon
201+
if ( processedIconRef.current === _icon ) {
202+
return
203+
}
204+
205+
// Check if icon exists in pageIcons Map
206+
// The Map structure is: [SVG string (key), { id: iconId, count: number } (value)]
207+
if ( _icon ) {
208+
const iconStr = String( _icon )
209+
let originalSvg = null
210+
let iconId = null
211+
212+
// Get the current state of the store
213+
const pageIcons = select( 'stackable/page-icons' ).getPageIcons()
214+
215+
// First, check if icon already exists in the store
216+
if ( pageIcons.has( iconStr ) ) {
217+
// Icon exists, use the existing ID and increment count
218+
const iconData = pageIcons.get( iconStr )
219+
iconId = iconData?.id || iconData
220+
originalSvg = iconStr
221+
addPageIconCount( iconStr, iconId )
222+
223+
// Re-check after dispatch to get the actual ID (handles race conditions)
224+
const updatedPageIcons = select( 'stackable/page-icons' ).getPageIcons()
225+
if ( updatedPageIcons.has( iconStr ) ) {
226+
const iconData = updatedPageIcons.get( iconStr )
227+
iconId = iconData?.id || iconData || iconId
228+
}
229+
} else if ( iconStr && iconStr.trim().startsWith( '<svg' ) && ! iconStr.includes( '<use' ) ) {
230+
// Icon doesn't exist, generate new ID and add it
231+
originalSvg = iconStr
232+
iconId = generateIconId()
233+
addPageIconCount( iconStr, iconId )
234+
235+
// After dispatch, immediately check the store again to get the actual ID
236+
// This handles the race condition where another component might have added
237+
// the same icon with a different ID
238+
const updatedPageIcons = select( 'stackable/page-icons' ).getPageIcons()
239+
if ( updatedPageIcons.has( iconStr ) ) {
240+
const iconData = updatedPageIcons.get( iconStr )
241+
// Use the ID from the store
242+
iconId = iconData?.id || iconData || iconId
243+
}
244+
}
245+
246+
if ( originalSvg && iconId ) {
247+
let viewBox = '0 0 24 24' // Default viewBox
248+
// Extract viewBox from the original SVG for proper dimensions
249+
const {
250+
viewBox: vb,
251+
width,
252+
height,
253+
} = extractSVGDimensions( originalSvg )
254+
if ( vb ) {
255+
viewBox = vb
256+
} else {
257+
// Fallback to width/height if viewBox is not available
258+
const finalWidth = width || 24
259+
const finalHeight = height || 24
260+
viewBox = `0 0 ${ finalWidth } ${ finalHeight }`
261+
}
262+
const newIcon = getSvgDef( `#stk-page-icons__${ iconId }`, viewBox )
263+
264+
// Only update state if the icon actually changed
265+
if ( newIcon !== lastIconValueRef.current ) {
266+
setIcon( newIcon )
267+
lastIconValueRef.current = newIcon
268+
}
269+
processedIconRef.current = _icon
270+
} else if ( ! _icon ) {
271+
// Clear processed ref when icon is removed
272+
processedIconRef.current = null
273+
if ( lastIconValueRef.current !== null ) {
274+
setIcon( null )
275+
lastIconValueRef.current = null
276+
}
277+
}
278+
} else {
279+
processedIconRef.current = null
280+
if ( lastIconValueRef.current !== null ) {
281+
setIcon( null )
282+
lastIconValueRef.current = null
283+
}
284+
}
285+
}, [ _icon ] )
286+
287+
useEffect( () => {
288+
return () => {
289+
if ( currentIconRef.current ) {
290+
dispatch( 'stackable/page-icons' ).removePageIcon( currentIconRef.current )
291+
}
292+
}
293+
}, [] )
294+
126295
if ( ! icon ) {
127296
return null
128297
}
@@ -171,6 +340,7 @@ export const Icon = props => {
171340
__deprecateUseRef={ popoverEl }
172341
onClose={ () => setIsOpen( false ) }
173342
onChange={ icon => {
343+
dispatch( 'stackable/page-icons' ).removePageIcon( _icon )
174344
if ( onChange === NOOP ) {
175345
updateAttributeHandler( 'icon' )( icon )
176346
} else {

src/components/font-awesome-icon/index.js

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
/**
22
* External dependencies
33
*/
4-
import {
5-
faGetIcon, faFetchIcon, createElementFromHTMLString,
6-
} from '~stackable/util'
4+
import { faGetIcon, faFetchIcon } from '~stackable/util'
75
import { pick } from 'lodash'
86

97
/**
@@ -55,6 +53,7 @@ const addSVGAriaLabel = ( _svgHTML, ariaLabel = '' ) => {
5553
/**
5654
* Given an SVG markup, sets an HTML attribute to the
5755
* HTML tag.
56+
* Optimized version using string manipulation instead of DOM operations
5857
*
5958
* @param {string} svgHTML
6059
* @param {Object} attributesToAdd
@@ -63,24 +62,62 @@ const addSVGAriaLabel = ( _svgHTML, ariaLabel = '' ) => {
6362
* @return {string} modified SVG HTML
6463
*/
6564
const addSVGAttributes = ( svgHTML, attributesToAdd = {}, attributesToRemove = [] ) => {
66-
const svgNode = createElementFromHTMLString( svgHTML )
67-
if ( ! svgNode ) {
65+
if ( ! svgHTML || typeof svgHTML !== 'string' ) {
6866
return ''
6967
}
7068

71-
Object.keys( attributesToAdd ).forEach( key => {
72-
svgNode.setAttribute( key, attributesToAdd[ key ] )
73-
} )
69+
// Find the opening <svg> tag (handles <svg>, <svg >, <svg...>)
70+
const svgTagMatch = svgHTML.match( /<svg\s*[^>]*>/i )
71+
if ( ! svgTagMatch ) {
72+
return svgHTML
73+
}
7474

75+
const svgTagStart = svgTagMatch.index
76+
const svgTagEnd = svgTagStart + svgTagMatch[ 0 ].length
77+
const svgTag = svgTagMatch[ 0 ]
78+
const restOfSvg = svgHTML.substring( svgTagEnd )
79+
80+
// Extract existing attributes from the SVG tag
81+
// Handles: key="value", key='value', key=value, and boolean attributes
82+
const attributes = {}
83+
// Extract the content between <svg and > (the attributes part)
84+
const attributesPart = svgTag.replace( /^<svg\s*/i, '' ).replace( />$/, '' )
85+
if ( attributesPart ) {
86+
// Match attribute name followed by = and value (with quotes or without)
87+
const attrRegex = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
88+
let attrMatch
89+
while ( ( attrMatch = attrRegex.exec( attributesPart ) ) !== null ) {
90+
const key = attrMatch[ 1 ]
91+
// Value can be in double quotes, single quotes, or unquoted
92+
const value = attrMatch[ 2 ] || attrMatch[ 3 ] || attrMatch[ 4 ] || ''
93+
attributes[ key ] = value
94+
}
95+
}
96+
97+
// Remove specified attributes
7598
attributesToRemove.forEach( key => {
76-
svgNode.removeAttribute( key )
99+
delete attributes[ key ]
77100
} )
78101

79-
return svgNode.outerHTML
102+
// Add or update attributes
103+
Object.assign( attributes, attributesToAdd )
104+
105+
// Rebuild the SVG tag
106+
const newAttributes = Object.keys( attributes )
107+
.map( key => {
108+
const value = attributes[ key ]
109+
// Escape double quotes in attribute values and wrap in double quotes
110+
const escapedValue = String( value ).replace( /"/g, '&quot;' )
111+
return `${ key }="${ escapedValue }"`
112+
} )
113+
.join( ' ' )
114+
115+
const newSvgTag = newAttributes ? `<svg ${ newAttributes }>` : '<svg>'
116+
return svgHTML.substring( 0, svgTagStart ) + newSvgTag + restOfSvg
80117
}
81118

82119
const FontAwesomeIcon = memo( props => {
83-
const {
120+
const {
84121
svgAttrsToAdd = { width: '32', height: '32' },
85122
svgAttrsToRemove = [ 'id', 'data-name' ],
86123
} = props

src/plugins/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import './theme-block-size'
66
import './design-library-button'
77
import './layout-picker-reset'
88
import './guided-modal-tour'
9+
import './page-icons'
910
// import './v2-migration-popup' // Probably 1.5yrs of checking for backward compatibility is enough.
1011
import './editor-device-preview-class'
1112
import './theme-block-style-inheritance'

src/plugins/page-icons/index.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* This loads the page icons in the editor.
3+
*/
4+
5+
/**
6+
* Internal dependencies
7+
*/
8+
import { PageIcons } from './page-icons'
9+
10+
/**
11+
* External dependencies
12+
*/
13+
import { useDeviceType } from '~stackable/hooks'
14+
import { createRoot } from '~stackable/util'
15+
16+
/** WordPress dependencies
17+
*/
18+
import { registerPlugin } from '@wordpress/plugins'
19+
import { useEffect } from '@wordpress/element'
20+
import { __ } from '@wordpress/i18n'
21+
import { useSelect } from '@wordpress/data'
22+
import domReady from '@wordpress/dom-ready'
23+
24+
const pageIconsWrapper = document?.createElementNS( 'http://www.w3.org/2000/svg', 'svg' )
25+
26+
pageIconsWrapper?.setAttribute( 'id', 'stk-page-icons' )
27+
28+
domReady( () => {
29+
if ( pageIconsWrapper ) {
30+
pageIconsWrapper.setAttribute( 'id', 'stk-page-icons' )
31+
pageIconsWrapper.setAttribute( 'style', 'display: none;' )
32+
createRoot( pageIconsWrapper ).render( <PageIcons /> )
33+
}
34+
} )
35+
36+
const PageIconsLoader = () => {
37+
const deviceType = useDeviceType()
38+
const editorDom = useSelect( select => {
39+
return select( 'stackable/editor-dom' ).getEditorDom()
40+
} )
41+
42+
/**
43+
* Render the page icons in the editor
44+
*/
45+
useEffect( () => {
46+
const editorBody = editorDom?.closest( 'body' )
47+
48+
if ( editorBody && ! editorBody.contains( pageIconsWrapper ) ) {
49+
editorBody.prepend( pageIconsWrapper )
50+
}
51+
}, [ deviceType, editorDom ] )
52+
53+
return null
54+
}
55+
56+
registerPlugin( 'stackable-page-icons', {
57+
render: PageIconsLoader,
58+
} )

0 commit comments

Comments
 (0)