Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/block-components/icon/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
172 changes: 171 additions & 1 deletion src/block-components/icon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -57,6 +58,67 @@ const LinearGradient = ( {

const NOOP = () => {}

const getSvgDef = ( href, viewBox = '0 0 24 24' ) => {
return `<svg viewBox="${ viewBox }"><use href="${ href }" xlink:href="${ href }"></use></svg>`
}

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 <svg> tag
const svgTagMatch = svgString.match( /<svg\s*[^>]*>/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',
Expand Down Expand Up @@ -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( '<svg' ) && ! iconStr.includes( '<use' ) ) {
// Icon doesn't exist, generate new ID and add it
originalSvg = iconStr
iconId = generateIconId()
addPageIconCount( iconStr, iconId )

// After dispatch, immediately check the store again to get the actual ID
// This handles the race condition where another component might have added
// the same icon with a different ID
const updatedPageIcons = select( 'stackable/page-icons' ).getPageIcons()
if ( updatedPageIcons.has( iconStr ) ) {
const iconData = updatedPageIcons.get( iconStr )
// Use the ID from the store
iconId = iconData?.id || iconData || iconId
}
}

if ( originalSvg && iconId ) {
let viewBox = '0 0 24 24' // Default viewBox
// Extract viewBox from the original SVG for proper dimensions
const {
viewBox: vb,
width,
height,
} = extractSVGDimensions( originalSvg )
if ( vb ) {
viewBox = vb
} else {
// Fallback to width/height if viewBox is not available
const finalWidth = width || 24
const finalHeight = height || 24
viewBox = `0 0 ${ finalWidth } ${ finalHeight }`
}
const newIcon = getSvgDef( `#stk-page-icons__${ iconId }`, viewBox )

// Only update state if the icon actually changed
if ( newIcon !== lastIconValueRef.current ) {
setIcon( newIcon )
lastIconValueRef.current = newIcon
}
processedIconRef.current = _icon
} else if ( ! _icon ) {
// Clear processed ref when icon is removed
processedIconRef.current = null
if ( lastIconValueRef.current !== null ) {
setIcon( null )
lastIconValueRef.current = null
}
}
} else {
processedIconRef.current = null
if ( lastIconValueRef.current !== null ) {
setIcon( null )
lastIconValueRef.current = null
}
}
}, [ _icon ] )

useEffect( () => {
return () => {
if ( currentIconRef.current ) {
dispatch( 'stackable/page-icons' ).removePageIcon( currentIconRef.current )
}
}
}, [] )

if ( ! icon ) {
return null
}
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 48 additions & 11 deletions src/components/font-awesome-icon/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/**
* External dependencies
*/
import {
faGetIcon, faFetchIcon, createElementFromHTMLString,
} from '~stackable/util'
import { faGetIcon, faFetchIcon } from '~stackable/util'
import { pick } from 'lodash'

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 <svg> tag (handles <svg>, <svg >, <svg...>)
const svgTagMatch = svgHTML.match( /<svg\s*[^>]*>/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 <svg and > (the attributes part)
const attributesPart = svgTag.replace( /^<svg\s*/i, '' ).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, '&quot;' )
return `${ key }="${ escapedValue }"`
} )
.join( ' ' )

const newSvgTag = newAttributes ? `<svg ${ newAttributes }>` : '<svg>'
return svgHTML.substring( 0, svgTagStart ) + newSvgTag + restOfSvg
}

const FontAwesomeIcon = memo( props => {
const {
const {
svgAttrsToAdd = { width: '32', height: '32' },
svgAttrsToRemove = [ 'id', 'data-name' ],
} = props
Expand Down
1 change: 1 addition & 0 deletions src/plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
58 changes: 58 additions & 0 deletions src/plugins/page-icons/index.js
Original file line number Diff line number Diff line change
@@ -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( <PageIcons /> )
}
} )

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,
} )
Loading
Loading