import React from 'react'; import { createRoot } from 'react-dom/client'; import './styles.css'; import IconPicker from './components/IconPicker'; function mountPickers() { const fields = document.querySelectorAll( '.openicon-field:not([data-openicon-mounted])' ); // Simple registry to manage React roots per instance const registry: Record = (( window as any ).__OPENICON_REGISTRY__ = (window as any).__OPENICON_REGISTRY__ || {}); fields.forEach((el) => { const host = el as HTMLElement; const keyOut = host.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const isClone = !!host.closest('.acf-clone'); const isHiddenTpl = host.style.display === 'none' && host.closest('[data-type]')?.classList.contains('acf-field'); const hasCloneIndex = !!keyOut?.name?.includes('acfcloneindex'); // Skip template elements (ACF uses these for cloning) if (isClone || isHiddenTpl) { return; } // Skip if field hasn't been finalised (still has acfcloneindex) // ACF replaces acfcloneindex with actual indices when field is ready if (hasCloneIndex) { return; // Skip - field not finalised yet } // Ensure finalised fields have a unique instanceId and id (clones can duplicate IDs) let instanceId = host.dataset.openiconInstanceId || host.id || ''; const keyOutForId = host.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const stillCloneIndex = !!keyOutForId?.name?.includes('acfcloneindex'); const idConflict = instanceId && document.querySelectorAll( `.openicon-field[data-openicon-instance-id="${instanceId}"]` ).length > 1; if (!instanceId || idConflict || stillCloneIndex) { const newId = `openicon_${Date.now().toString(16)}_${Math.random() .toString(36) .slice(2)}`; host.id = newId; host.dataset.openiconInstanceId = newId; instanceId = newId; } host.dataset.openiconMounted = '1'; // CRITICAL: Check if we already have a React root for this instance ID // Properly unmount any existing root before creating a new one const existing = registry[instanceId]; const existingMountId = `openicon-mount-${instanceId}`; if (existing) { try { existing.root.unmount?.(); } catch {} try { existing.mount.remove(); } catch {} delete registry[instanceId]; } else { const stray = document.getElementById(existingMountId); if (stray) { stray.remove(); } } // Lite version always uses heroicons const provider = 'heroicons'; const version = host.dataset.openiconVersion || 'latest'; const useLastColor = host.dataset.openiconUseLastColor === '1'; const fieldKey = host.dataset.openiconFieldKey || ''; const fieldGroupKey = host.dataset.openiconFieldGroupKey || ''; const preview = host.querySelector('[data-openicon-preview]') as HTMLElement; const inputKey = host.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement; const inputSvg = host.querySelector( '[data-openicon-svg-out]' ) as HTMLTextAreaElement; const openBtn = host.querySelector( '[data-openicon-open]' ) as HTMLButtonElement; // Create a mount point for the React modal - use instance ID for consistency const mount = document.createElement('div'); mount.id = existingMountId; document.body.appendChild(mount); // Create the React root once per field const root = createRoot(mount); root.render( { // Re-query the LIVE DOM element by ID to handle ACF's DOM manipulation let liveHost = document.getElementById(host.id) as HTMLElement | null; if (!liveHost) { return; } // CRITICAL: Check if the field we found is still in clone/template state // ACF may have re-cloned it, or we mounted to a clone that became a template again const testInput = liveHost.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const isCloneState = testInput?.name.includes('acfcloneindex') || false; const isCloneElement = !!liveHost.closest('.acf-clone'); // If we're operating on a clone, find the REAL field if (isCloneState || isCloneElement) { const instanceId = liveHost.dataset.openiconInstanceId; const allFields = document.querySelectorAll('.openicon-field'); let actualField: HTMLElement | null = null; for (const field of allFields) { const f = field as HTMLElement; // Match by instance ID (which should be unique per real field) if (f.dataset.openiconInstanceId === instanceId) { const testInp = f.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const notClone = !f.closest('.acf-clone'); const notCloneIndex = !testInp?.name.includes('acfcloneindex'); if (notClone && notCloneIndex) { actualField = f; break; } } } if (actualField) { liveHost = actualField; } } const currentInputKey = liveHost.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement; const currentInputSvg = liveHost.querySelector( '[data-openicon-svg-out]' ) as HTMLTextAreaElement; const currentPreview = liveHost.querySelector( '[data-openicon-preview]' ) as HTMLElement; const currentOpenBtn = liveHost.querySelector( '[data-openicon-open]' ) as HTMLButtonElement; const currentClearBtn = liveHost.querySelector( '[data-openicon-clear]' ) as HTMLButtonElement; const colorTokenInput = liveHost.querySelector( '[data-openicon-color-token-out]' ) as HTMLInputElement; // Always update the key input (even if SVG is not yet available) if (currentInputKey) { currentInputKey.value = item.key; currentInputKey.dispatchEvent( new Event('input', { bubbles: true }) ); currentInputKey.dispatchEvent( new Event('change', { bubbles: true }) ); } if (colorTokenInput && item.color?.token) { colorTokenInput.value = item.color.token; colorTokenInput.dispatchEvent( new Event('input', { bubbles: true }) ); } // If SVG is not available yet, show a loading state in the preview if (currentPreview && !item.svg && item.key) { // Show a placeholder or loading indicator currentPreview.innerHTML = '
'; // Remove display:none from style and ensure display:flex is set const styleAttr = currentPreview.getAttribute('style') || ''; const styleParts = styleAttr.split(';').filter((s) => { const trimmed = s.trim(); return trimmed && !trimmed.toLowerCase().startsWith('display'); }); styleParts.push( 'display:flex', 'align-items:center', 'justify-content:center' ); const newStyle = styleParts.join(';') + ';'; currentPreview.setAttribute('style', newStyle); } // Helper function to apply colour to SVG (matches IconPicker logic) const applyColorToSvg = (svg: string, color: string): string => { if (!svg || !color) return svg; // Detect if icon uses fill or stroke (or both) const hasStroke = /\bstroke(?!-)\s*=/i.test(svg); const hasFill = /\bfill(?!-)\s*=/i.test(svg) && !/fill\s*=\s*["']none["']/i.test(svg); let result = svg; // Apply colour to stroke if present if (hasStroke) { result = result.replace(/\bstroke(?!-)\s*=\s*["']?[^"'\s>]*["']?/gi, `stroke="${color}"`); } // Apply colour to fill if present and not explicitly set to "none" if (hasFill) { result = result.replace(/\bfill(?!-)\s*=\s*["']?[^"'\s>]*["']?/gi, `fill="${color}"`); } return result; }; if (currentInputSvg && item.svg) { const colored = item.color?.hex || '#111111'; const svgWithColor = applyColorToSvg(item.svg, colored); currentInputSvg.value = svgWithColor; currentInputSvg.dispatchEvent( new Event('input', { bubbles: true }) ); currentInputSvg.dispatchEvent( new Event('change', { bubbles: true }) ); } // Update preview if SVG is available (even if called asynchronously after modal closes) if (currentPreview && item.svg) { const colored = item.color?.hex || '#111111'; const svgWithColor = applyColorToSvg(item.svg, colored); currentPreview.innerHTML = svgWithColor; // Remove display:none from style and ensure display:flex is set const styleAttr = currentPreview.getAttribute('style') || ''; const styleParts = styleAttr.split(';').filter((s) => { const trimmed = s.trim(); return trimmed && !trimmed.toLowerCase().startsWith('display'); }); styleParts.push( 'display:flex', 'align-items:center', 'justify-content:center' ); const newStyle = styleParts.join(';') + ';'; currentPreview.setAttribute('style', newStyle); const svg = currentPreview.firstElementChild as HTMLElement | null; if (svg) { svg.setAttribute('width', '24'); svg.setAttribute('height', '24'); } // Force a reflow void currentPreview.offsetHeight; } if (currentOpenBtn) { currentOpenBtn.style.display = ''; currentOpenBtn.textContent = 'Change Icon'; } if (currentClearBtn) { currentClearBtn.style.display = ''; } }} /> ); // Record root to registry for proper lifecycle management registry[instanceId] = { root, mount }; // Attach open button click handler - ensure only one listener per instance if (openBtn) { // Namespaced listener keys via dataset if ((openBtn as any).__openiconBound__) { // Already bound for this instance } else { (openBtn as any).__openiconBound__ = true; const clickHandler = () => { window.dispatchEvent( new CustomEvent('openicon-open-modal', { detail: { instanceId }, }) ); }; openBtn.addEventListener('click', clickHandler); const prewarm = () => { window.dispatchEvent(new CustomEvent('openicon-prewarm')); }; openBtn.addEventListener('mouseenter', prewarm, { passive: true }); openBtn.addEventListener('focus', prewarm, { passive: true }); } } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountPickers); } else { mountPickers(); } // ACF field lifecycle events - append_field fires AFTER field is finalised (window as any).acf?.addAction?.('append_field', ($field: any) => { setTimeout(() => mountPickers(), 50); }); // ready_field fires when field is ready and finalised (window as any).acf?.addAction?.('ready_field', ($field: any) => { setTimeout(() => mountPickers(), 50); }); // ACF ready event (initial page load) (window as any).acf?.addAction?.('ready', () => { mountPickers(); }); // Event delegation fallback: ensure clicks on [data-openicon-open] trigger the modal document.addEventListener( 'click', (ev) => { const target = (ev.target as HTMLElement)?.closest?.( '[data-openicon-open]' ) as HTMLButtonElement | null; if (!target) return; const host = target.closest('.openicon-field') as HTMLElement | null; const instanceId = host?.dataset.openiconInstanceId || host?.id || ''; window.dispatchEvent( new CustomEvent('openicon-open-modal', { detail: { instanceId } }) ); ev.preventDefault(); }, true ); // Event delegation for remove/clear button: ensure clicks on [data-openicon-clear] work for dynamically added fields document.addEventListener( 'click', (ev) => { const target = (ev.target as HTMLElement)?.closest?.( '[data-openicon-clear]' ) as HTMLButtonElement | null; if (!target) return; const host = target.closest('.openicon-field') as HTMLElement | null; if (!host) return; // Re-query the LIVE DOM element to handle ACF's DOM manipulation const instanceId = host.dataset.openiconInstanceId || host.id || ''; let liveHost = document.getElementById(host.id) as HTMLElement | null; if (!liveHost) { // Fallback: try to find by instance ID liveHost = document.querySelector( `.openicon-field[data-openicon-instance-id="${instanceId}"]` ) as HTMLElement | null; } if (!liveHost) return; // Check if field is in clone state const testInput = liveHost.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const isCloneState = testInput?.name.includes('acfcloneindex') || false; const isCloneElement = !!liveHost.closest('.acf-clone'); // If we're operating on a clone, find the REAL field if (isCloneState || isCloneElement) { const allFields = document.querySelectorAll('.openicon-field'); let actualField: HTMLElement | null = null; for (const field of allFields) { const f = field as HTMLElement; if (f.dataset.openiconInstanceId === instanceId) { const testInp = f.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const notClone = !f.closest('.acf-clone'); const notCloneIndex = !testInp?.name.includes('acfcloneindex'); if (notClone && notCloneIndex) { actualField = f; break; } } } if (actualField) { liveHost = actualField; } } // Clear the icon data const keyInput = liveHost.querySelector( '[data-openicon-key-out]' ) as HTMLInputElement | null; const svgInput = liveHost.querySelector( '[data-openicon-svg-out]' ) as HTMLTextAreaElement | null; const tokenInput = liveHost.querySelector( '[data-openicon-color-token-out]' ) as HTMLInputElement | null; const preview = liveHost.querySelector( '[data-openicon-preview]' ) as HTMLElement | null; const openBtn = liveHost.querySelector( '[data-openicon-open]' ) as HTMLButtonElement | null; const clearBtn = liveHost.querySelector( '[data-openicon-clear]' ) as HTMLButtonElement | null; if (keyInput) { keyInput.value = ''; keyInput.dispatchEvent(new Event('input', { bubbles: true })); keyInput.dispatchEvent(new Event('change', { bubbles: true })); } if (svgInput) { svgInput.value = ''; svgInput.dispatchEvent(new Event('input', { bubbles: true })); svgInput.dispatchEvent(new Event('change', { bubbles: true })); } if (tokenInput) { tokenInput.value = ''; tokenInput.dispatchEvent(new Event('input', { bubbles: true })); } if (preview) { preview.innerHTML = ''; const styleAttr = preview.getAttribute('style') || ''; const styleParts = styleAttr.split(';').filter((s) => { const trimmed = s.trim(); return trimmed && !trimmed.toLowerCase().startsWith('display'); }); styleParts.push('display:none'); const newStyle = styleParts.join(';') + ';'; preview.setAttribute('style', newStyle); } if (openBtn) { openBtn.style.display = ''; openBtn.textContent = 'Select Icon'; } if (clearBtn) { clearBtn.style.display = 'none'; } ev.preventDefault(); }, true );