import { __ } from '@wordpress/i18n'; import { useBlockProps } from '@wordpress/block-editor'; import { useEffect, useRef, useState } from '@wordpress/element'; import Inspector from './inspector'; import Style from './style'; import { DEFAULT_CODE_PLACEHOLDER, LANGUAGE_OPTIONS } from './constants'; const Edit = (props) => { const { attributes, setAttributes, clientId, isSelected } = props; const { blockId, blockMeta, codeContent, language, codeVariants = [], showCopyButton, showHeader, showLineNumbers, theme, resOption, fileName, showFileIcon, fileIcon, showTrafficLights, } = attributes; // Combined language list: the primary language/code first, then each // non-empty variant. One entry → single-language (as before); more → the // header shows a language dropdown to switch the previewed panel. const variants = [ { language, codeContent, fileName }, ...codeVariants.filter((v) => v && (v.codeContent || '').length > 0), ]; const isMulti = variants.length > 1; const [activeIndex, setActiveIndex] = useState(0); const [langOpen, setLangOpen] = useState(false); const safeIndex = activeIndex < variants.length ? activeIndex : 0; const active = variants[safeIndex] || { language, codeContent, fileName }; const languageLabel = (lang) => (LANGUAGE_OPTIONS.find((o) => o.value === lang) || {}).label || lang; // Set unique block ID useEffect(() => { const BLOCK_PREFIX = 'betterdocs-code-snippet'; if (!blockId) { setAttributes({ blockId: BLOCK_PREFIX + '-' + clientId.substr(0, 8) }); } }, []); // Set block meta useEffect(() => { setAttributes({ blockMeta: { desktop: resOption === 'Desktop' ? true : false, tab: resOption === 'Tablet' ? true : false, mobile: resOption === 'Mobile' ? true : false, }, }); }, [resOption]); const blockRef = useRef(null); const blockProps = useBlockProps({ className: `betterdocs-code-snippet-block ${blockId}`, ref: blockRef }); // Editor-side preview of the copy button: hover tooltip + "Copied!" state, // matching the frontend. Placement and the icon swap are handled by CSS // (`.show` / `.is-copied`), so this only toggles classes and label text. useEffect(() => { if (!blockRef.current) return; const copyButton = blockRef.current.querySelector('.betterdocs-code-snippet-copy-button'); const tooltip = blockRef.current.querySelector('.betterdocs-code-snippet-tooltip'); if (!copyButton || !tooltip) return; let resetTimer; const showTooltip = () => tooltip.classList.add('show'); const hideTooltip = () => { if (copyButton.classList.contains('is-copied')) return; tooltip.classList.remove('show'); }; const handleCopyClick = () => { clearTimeout(resetTimer); copyButton.classList.add('is-copied'); tooltip.textContent = __('Copied!', 'betterdocs'); tooltip.classList.add('show', 'is-copied'); resetTimer = setTimeout(() => { copyButton.classList.remove('is-copied'); tooltip.textContent = __('Copy', 'betterdocs'); tooltip.classList.remove('is-copied'); if (!copyButton.matches(':hover')) { tooltip.classList.remove('show'); } }, 2000); }; copyButton.addEventListener('mouseenter', showTooltip); copyButton.addEventListener('mouseleave', hideTooltip); copyButton.addEventListener('blur', hideTooltip); copyButton.addEventListener('click', handleCopyClick); return () => { clearTimeout(resetTimer); copyButton.removeEventListener('mouseenter', showTooltip); copyButton.removeEventListener('mouseleave', hideTooltip); copyButton.removeEventListener('blur', hideTooltip); copyButton.removeEventListener('click', handleCopyClick); }; }, [showCopyButton, showHeader, showTrafficLights, showFileIcon]); const activeLanguage = active.language || 'javascript'; const displayContent = active.codeContent || DEFAULT_CODE_PLACEHOLDER; // Initialize syntax highlighting for editor preview useEffect(() => { if (!blockRef.current) return; // Highlight.js is bundled with the plugin (self-hosted, no CDN); URLs // come from the localized config in CodeSnippet::register_scripts(). const hljsConfig = window.BetterDocsCodeSnippetConfig || {}; // (Re)apply the theme CSS into the SAME document as the block. The editor // renders block previews inside an iframe, so the top document's // would NOT style the iframe's code — this is what makes the editor's // dark colors identical to the frontend. const ensureThemeCss = (doc) => { const themeHref = theme === 'dark' ? hljsConfig.cssDark : hljsConfig.cssLight; if (!themeHref || !doc || !doc.head) return; const existing = doc.getElementById('betterdocs-hljs-theme'); if (existing && existing.getAttribute('href') === themeHref) return; if (existing) existing.remove(); const link = doc.createElement('link'); link.id = 'betterdocs-hljs-theme'; link.rel = 'stylesheet'; link.href = themeHref; doc.head.appendChild(link); }; const loadHighlightJS = () => { return new Promise((resolve) => { if (window.hljs) { resolve(window.hljs); return; } if (!hljsConfig.hljsUrl) { // No bundled URL — resolve without highlighting rather than hit a CDN. resolve(null); return; } // The hljs library is a global — load it once in the top document. if (!document.getElementById('betterdocs-hljs-script')) { const script = document.createElement('script'); script.id = 'betterdocs-hljs-script'; script.src = hljsConfig.hljsUrl; script.onload = () => { if (window.hljs) { resolve(window.hljs); } }; document.head.appendChild(script); } else { // Script already exists, wait for it to load const checkHljs = () => { if (window.hljs) { resolve(window.hljs); } else { setTimeout(checkHljs, 100); } }; checkHljs(); } }); }; const applyHighlighting = async () => { try { const codeElement = blockRef.current?.querySelector('.betterdocs-code-snippet-code'); // Theme CSS must live in the block's document (iframe-safe). ensureThemeCss(codeElement ? codeElement.ownerDocument : document); const hljs = await loadHighlightJS(); if (codeElement && hljs) { // "cURL" has no highlight.js grammar — alias it to bash. try { if (hljs.registerAliases) { hljs.registerAliases('curl', { languageName: 'bash' }); } } catch (e) {} // Clear any existing highlighting codeElement.removeAttribute('data-highlighted'); codeElement.className = `betterdocs-code-snippet-code language-${activeLanguage}`; // Clear existing highlighted content const codeInner = codeElement.querySelector('code'); if (codeInner) { codeInner.innerHTML = displayContent; } // Apply new highlighting hljs.highlightElement(codeElement); } } catch (error) { console.warn('Failed to load or apply syntax highlighting:', error); } }; // Small delay to ensure DOM is updated const timeoutId = setTimeout(applyHighlighting, 100); return () => clearTimeout(timeoutId); }, [displayContent, activeLanguage, theme, showLineNumbers, safeIndex]); // Get file icon based on language const getLanguageIcon = (lang) => { const icons = { 'javascript': '📄', 'typescript': '📘', 'jsx': '⚛️', 'tsx': '⚛️', 'html': '🌐', 'css': '🎨', 'scss': '🎨', 'sass': '🎨', 'less': '🎨', 'php': '🐘', 'python': '🐍', 'java': '☕', 'csharp': '🔷', 'cpp': '⚙️', 'c': '⚙️', 'ruby': '💎', 'go': '🐹', 'rust': '🦀', 'swift': '🦉', 'kotlin': '🎯', 'sql': '🗃️', 'json': '📋', 'yaml': '📋', 'xml': '📄', 'markdown': '📝', 'curl': '💻', 'bash': '💻', 'shell': '💻', 'powershell': '💻', 'dockerfile': '🐳', }; return icons[lang] || '📄'; }; return ( <> {isSelected && }