PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / gutenberg / blocks / code-snippet / src / edit.js

edit.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/gutenberg/blocks/code-snippet/src/edit.js

393 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __ } from '@wordpress/i18n';
2 import { useBlockProps } from '@wordpress/block-editor';
3 import { useEffect, useRef, useState } from '@wordpress/element';
4
5 import Inspector from './inspector';
6 import Style from './style';
7 import { DEFAULT_CODE_PLACEHOLDER, LANGUAGE_OPTIONS } from './constants';
8
9 const Edit = (props) => {
10 const { attributes, setAttributes, clientId, isSelected } = props;
11 const {
12 blockId,
13 blockMeta,
14 codeContent,
15 language,
16 codeVariants = [],
17 showCopyButton,
18 showHeader,
19 showLineNumbers,
20 theme,
21 resOption,
22 fileName,
23 showFileIcon,
24 fileIcon,
25 showTrafficLights,
26 } = attributes;
27
28 // Combined language list: the primary language/code first, then each
29 // non-empty variant. One entry → single-language (as before); more → the
30 // header shows a language dropdown to switch the previewed panel.
31 const variants = [
32 { language, codeContent, fileName },
33 ...codeVariants.filter((v) => v && (v.codeContent || '').length > 0),
34 ];
35 const isMulti = variants.length > 1;
36
37 const [activeIndex, setActiveIndex] = useState(0);
38 const [langOpen, setLangOpen] = useState(false);
39 const safeIndex = activeIndex < variants.length ? activeIndex : 0;
40 const active = variants[safeIndex] || { language, codeContent, fileName };
41 const languageLabel = (lang) =>
42 (LANGUAGE_OPTIONS.find((o) => o.value === lang) || {}).label || lang;
43
44 // Set unique block ID
45 useEffect(() => {
46 const BLOCK_PREFIX = 'betterdocs-code-snippet';
47 if (!blockId) {
48 setAttributes({ blockId: BLOCK_PREFIX + '-' + clientId.substr(0, 8) });
49 }
50 }, []);
51
52 // Set block meta
53 useEffect(() => {
54 setAttributes({
55 blockMeta: {
56 desktop: resOption === 'Desktop' ? true : false,
57 tab: resOption === 'Tablet' ? true : false,
58 mobile: resOption === 'Mobile' ? true : false,
59 },
60 });
61 }, [resOption]);
62
63 const blockRef = useRef(null);
64
65 const blockProps = useBlockProps({
66 className: `betterdocs-code-snippet-block ${blockId}`,
67 ref: blockRef
68 });
69
70 // Editor-side preview of the copy button: hover tooltip + "Copied!" state,
71 // matching the frontend. Placement and the icon swap are handled by CSS
72 // (`.show` / `.is-copied`), so this only toggles classes and label text.
73 useEffect(() => {
74 if (!blockRef.current) return;
75
76 const copyButton = blockRef.current.querySelector('.betterdocs-code-snippet-copy-button');
77 const tooltip = blockRef.current.querySelector('.betterdocs-code-snippet-tooltip');
78
79 if (!copyButton || !tooltip) return;
80
81 let resetTimer;
82
83 const showTooltip = () => tooltip.classList.add('show');
84 const hideTooltip = () => {
85 if (copyButton.classList.contains('is-copied')) return;
86 tooltip.classList.remove('show');
87 };
88
89 const handleCopyClick = () => {
90 clearTimeout(resetTimer);
91 copyButton.classList.add('is-copied');
92 tooltip.textContent = __('Copied!', 'betterdocs');
93 tooltip.classList.add('show', 'is-copied');
94
95 resetTimer = setTimeout(() => {
96 copyButton.classList.remove('is-copied');
97 tooltip.textContent = __('Copy', 'betterdocs');
98 tooltip.classList.remove('is-copied');
99 if (!copyButton.matches(':hover')) {
100 tooltip.classList.remove('show');
101 }
102 }, 2000);
103 };
104
105 copyButton.addEventListener('mouseenter', showTooltip);
106 copyButton.addEventListener('mouseleave', hideTooltip);
107 copyButton.addEventListener('blur', hideTooltip);
108 copyButton.addEventListener('click', handleCopyClick);
109
110 return () => {
111 clearTimeout(resetTimer);
112 copyButton.removeEventListener('mouseenter', showTooltip);
113 copyButton.removeEventListener('mouseleave', hideTooltip);
114 copyButton.removeEventListener('blur', hideTooltip);
115 copyButton.removeEventListener('click', handleCopyClick);
116 };
117 }, [showCopyButton, showHeader, showTrafficLights, showFileIcon]);
118
119 const activeLanguage = active.language || 'javascript';
120 const displayContent = active.codeContent || DEFAULT_CODE_PLACEHOLDER;
121
122 // Initialize syntax highlighting for editor preview
123 useEffect(() => {
124 if (!blockRef.current) return;
125
126 // Highlight.js is bundled with the plugin (self-hosted, no CDN); URLs
127 // come from the localized config in CodeSnippet::register_scripts().
128 const hljsConfig = window.BetterDocsCodeSnippetConfig || {};
129
130 // (Re)apply the theme CSS into the SAME document as the block. The editor
131 // renders block previews inside an iframe, so the top document's <head>
132 // would NOT style the iframe's code — this is what makes the editor's
133 // dark colors identical to the frontend.
134 const ensureThemeCss = (doc) => {
135 const themeHref = theme === 'dark' ? hljsConfig.cssDark : hljsConfig.cssLight;
136 if (!themeHref || !doc || !doc.head) return;
137
138 const existing = doc.getElementById('betterdocs-hljs-theme');
139 if (existing && existing.getAttribute('href') === themeHref) return;
140 if (existing) existing.remove();
141
142 const link = doc.createElement('link');
143 link.id = 'betterdocs-hljs-theme';
144 link.rel = 'stylesheet';
145 link.href = themeHref;
146 doc.head.appendChild(link);
147 };
148
149 const loadHighlightJS = () => {
150 return new Promise((resolve) => {
151 if (window.hljs) {
152 resolve(window.hljs);
153 return;
154 }
155
156 if (!hljsConfig.hljsUrl) {
157 // No bundled URL — resolve without highlighting rather than hit a CDN.
158 resolve(null);
159 return;
160 }
161
162 // The hljs library is a global — load it once in the top document.
163 if (!document.getElementById('betterdocs-hljs-script')) {
164 const script = document.createElement('script');
165 script.id = 'betterdocs-hljs-script';
166 script.src = hljsConfig.hljsUrl;
167 script.onload = () => {
168 if (window.hljs) {
169 resolve(window.hljs);
170 }
171 };
172 document.head.appendChild(script);
173 } else {
174 // Script already exists, wait for it to load
175 const checkHljs = () => {
176 if (window.hljs) {
177 resolve(window.hljs);
178 } else {
179 setTimeout(checkHljs, 100);
180 }
181 };
182 checkHljs();
183 }
184 });
185 };
186
187 const applyHighlighting = async () => {
188 try {
189 const codeElement = blockRef.current?.querySelector('.betterdocs-code-snippet-code');
190
191 // Theme CSS must live in the block's document (iframe-safe).
192 ensureThemeCss(codeElement ? codeElement.ownerDocument : document);
193
194 const hljs = await loadHighlightJS();
195
196 if (codeElement && hljs) {
197 // "cURL" has no highlight.js grammar — alias it to bash.
198 try {
199 if (hljs.registerAliases) {
200 hljs.registerAliases('curl', { languageName: 'bash' });
201 }
202 } catch (e) {}
203
204 // Clear any existing highlighting
205 codeElement.removeAttribute('data-highlighted');
206 codeElement.className = `betterdocs-code-snippet-code language-${activeLanguage}`;
207
208 // Clear existing highlighted content
209 const codeInner = codeElement.querySelector('code');
210 if (codeInner) {
211 codeInner.innerHTML = displayContent;
212 }
213
214 // Apply new highlighting
215 hljs.highlightElement(codeElement);
216 }
217 } catch (error) {
218 console.warn('Failed to load or apply syntax highlighting:', error);
219 }
220 };
221
222 // Small delay to ensure DOM is updated
223 const timeoutId = setTimeout(applyHighlighting, 100);
224
225 return () => clearTimeout(timeoutId);
226 }, [displayContent, activeLanguage, theme, showLineNumbers, safeIndex]);
227
228 // Get file icon based on language
229 const getLanguageIcon = (lang) => {
230 const icons = {
231 'javascript': '📄',
232 'typescript': '📘',
233 'jsx': '⚛️',
234 'tsx': '⚛️',
235 'html': '🌐',
236 'css': '🎨',
237 'scss': '🎨',
238 'sass': '🎨',
239 'less': '🎨',
240 'php': '🐘',
241 'python': '🐍',
242 'java': '☕',
243 'csharp': '🔷',
244 'cpp': '⚙️',
245 'c': '⚙️',
246 'ruby': '💎',
247 'go': '🐹',
248 'rust': '🦀',
249 'swift': '🦉',
250 'kotlin': '🎯',
251 'sql': '🗃️',
252 'json': '📋',
253 'yaml': '📋',
254 'xml': '📄',
255 'markdown': '📝',
256 'curl': '💻',
257 'bash': '💻',
258 'shell': '💻',
259 'powershell': '💻',
260 'dockerfile': '🐳',
261 };
262 return icons[lang] || '📄';
263 };
264
265 return (
266 <>
267 {isSelected && <Inspector {...props} />}
268 <Style {...props} />
269 <div {...blockProps}>
270 <div className={`betterdocs-code-snippet-wrapper theme-${theme} ${blockId}${isMulti ? ' is-multi-lang' : ''}`}
271 data-language={activeLanguage}
272 data-copy-button={showCopyButton ? 'true' : 'false'}>
273 {showHeader && (
274 <div className="betterdocs-code-snippet-header betterdocs-file-preview-header">
275 <div className="betterdocs-file-preview-left">
276 {showTrafficLights && (
277 <div className="betterdocs-traffic-lights">
278 <span className="traffic-light traffic-light-red"></span>
279 <span className="traffic-light traffic-light-yellow"></span>
280 <span className="traffic-light traffic-light-green"></span>
281 </div>
282 )}
283
284 {!isMulti && (
285 <div className="betterdocs-file-info">
286 {showFileIcon && (
287 <div className="betterdocs-file-icon">
288 {fileIcon ? (
289 <img src={fileIcon} alt={__('File icon', 'betterdocs')} />
290 ) : (
291 <span className="betterdocs-file-icon-emoji">{getLanguageIcon(activeLanguage)}</span>
292 )}
293 </div>
294 )}
295
296 {active.fileName && (
297 <div className="betterdocs-file-name">
298 <span className="file-name-text">{active.fileName}</span>
299 </div>
300 )}
301 </div>
302 )}
303 </div>
304
305 <div className="betterdocs-file-preview-right">
306 {isMulti && (
307 <div className={`betterdocs-code-lang-dropdown${langOpen ? ' is-open' : ''}`}>
308 <button
309 type="button"
310 className="betterdocs-code-lang-toggle"
311 onClick={() => setLangOpen(! langOpen)}
312 aria-haspopup="listbox"
313 aria-expanded={langOpen}
314 >
315 <span className="betterdocs-code-lang-current">
316 {showFileIcon && (
317 <span className="betterdocs-file-icon-emoji">{getLanguageIcon(activeLanguage)}</span>
318 )}
319 <span className="betterdocs-code-lang-label">{languageLabel(activeLanguage)}</span>
320 </span>
321 <svg className="betterdocs-code-lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
322 <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
323 </svg>
324 </button>
325 <ul className="betterdocs-code-lang-menu" role="listbox">
326 {variants.map((v, i) => (
327 // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
328 <li
329 key={i}
330 className={`betterdocs-code-lang-option${i === safeIndex ? ' is-active' : ''}`}
331 role="option"
332 aria-selected={i === safeIndex}
333 onClick={() => { setActiveIndex(i); setLangOpen(false); }}
334 >
335 {showFileIcon && (
336 <span className="betterdocs-file-icon-emoji">{getLanguageIcon(v.language)}</span>
337 )}
338 <span className="betterdocs-code-lang-label">{languageLabel(v.language)}</span>
339 </li>
340 ))}
341 </ul>
342 </div>
343 )}
344 {showCopyButton && (
345 <div className="betterdocs-code-snippet-copy-container">
346 <button className="betterdocs-code-snippet-copy-button" type="button" aria-label={__('Copy code to clipboard', 'betterdocs')}>
347 <span className="betterdocs-code-snippet-copy-icon" aria-hidden="true">
348 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
349 <rect x="9" y="9" width="12.5" height="12.5" rx="3" stroke="currentColor" strokeWidth="1.7"/>
350 <path d="M15.5 5.75V5A2.5 2.5 0 0 0 13 2.5H5A2.5 2.5 0 0 0 2.5 5v8A2.5 2.5 0 0 0 5 15.5h.75" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/>
351 </svg>
352 </span>
353 <span className="betterdocs-code-snippet-copied-icon" aria-hidden="true">
354 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
355 <path d="M20 6.5 9.5 17 4 11.5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
356 </svg>
357 </span>
358 </button>
359 <span className="betterdocs-code-snippet-tooltip" role="status" data-copy-label={__('Copy', 'betterdocs')} data-copied-label={__('Copied!', 'betterdocs')} data-error-label={__('Copy failed', 'betterdocs')}>{__('Copy', 'betterdocs')}</span>
360 </div>
361 )}
362 </div>
363 </div>
364 )}
365 <div className="betterdocs-code-snippet-content">
366 {/* The flex row (line numbers + code) lives on the panel,
367 mirroring views/templates/parts/code-snippet.php. Without
368 this wrapper `.content` stays display:block and the code
369 stacks below the line numbers — rendering blank in the
370 iframed editor canvas. */}
371 <div className="betterdocs-code-snippet-panel is-active">
372 {showLineNumbers && (
373 <div className="betterdocs-code-snippet-line-numbers" aria-hidden="true">
374 {displayContent.split('\n').map((_, index) => (
375 <div key={index} className="line-number">
376 {index + 1}
377 </div>
378 ))}
379 </div>
380 )}
381 <pre className={`betterdocs-code-snippet-code language-${activeLanguage}`}>
382 {displayContent}
383 </pre>
384 </div>
385 </div>
386 </div>
387 </div>
388 </>
389 );
390 };
391
392 export default Edit;
393