Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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