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 / public / js / code-snippet.js

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

507 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * BetterDocs Code Snippet Frontend JavaScript
3 * Handles syntax highlighting and copy-to-clipboard functionality
4 */
5
6 (function() {
7 'use strict';
8
9 // Global namespace
10 window.BetterDocsCodeSnippet = window.BetterDocsCodeSnippet || {};
11
12 // Configuration. Highlight.js is bundled with the plugin (self-hosted, no
13 // CDN); URLs come from the localized config set in CodeSnippet::register_scripts().
14 const localConfig = window.BetterDocsCodeSnippetConfig || {};
15 const config = {
16 highlightJsUrl: localConfig.hljsUrl || '',
17 highlightJsCssLight: localConfig.cssLight || '',
18 highlightJsCssDark: localConfig.cssDark || '',
19 copySuccessTimeout: 2000,
20 retryAttempts: 3,
21 retryDelay: 1000
22 };
23
24 // State management
25 let highlightJsLoaded = false;
26 let highlightJsLoading = false;
27 let copyButtonsInitialized = false;
28
29 /**
30 * Utility function to load external scripts
31 */
32 function loadScript(src, callback) {
33 const script = document.createElement('script');
34 script.src = src;
35 script.onload = callback;
36 script.onerror = function() {
37 console.warn('BetterDocs Code Snippet: Failed to load script:', src);
38 if (callback) callback(false);
39 };
40 document.head.appendChild(script);
41 }
42
43 /**
44 * Utility function to load external stylesheets
45 */
46 function loadStylesheet(href) {
47 // Check if stylesheet is already loaded
48 const existingLink = document.querySelector(`link[href="${href}"]`);
49 if (existingLink) return;
50
51 const link = document.createElement('link');
52 link.rel = 'stylesheet';
53 link.href = href;
54 link.onerror = function() {
55 console.warn('BetterDocs Code Snippet: Failed to load stylesheet:', href);
56 };
57 document.head.appendChild(link);
58 }
59
60 /**
61 * Load Highlight.js library and CSS
62 */
63 function loadHighlightJs(callback) {
64 if (highlightJsLoaded) {
65 if (callback) callback(true);
66 return;
67 }
68
69 if (highlightJsLoading) {
70 // Wait for current loading to complete
71 const checkLoaded = setInterval(function() {
72 if (highlightJsLoaded || !highlightJsLoading) {
73 clearInterval(checkLoaded);
74 if (callback) callback(highlightJsLoaded);
75 }
76 }, 100);
77 return;
78 }
79
80 // No bundled URL available — skip highlighting rather than request a CDN.
81 if (!config.highlightJsUrl) {
82 if (callback) callback(false);
83 return;
84 }
85
86 highlightJsLoading = true;
87
88 // Load CSS based on theme
89 const darkThemeSnippets = document.querySelectorAll('.betterdocs-code-snippet-wrapper.theme-dark');
90 const lightThemeSnippets = document.querySelectorAll('.betterdocs-code-snippet-wrapper.theme-light');
91
92 if (darkThemeSnippets.length > 0) {
93 loadStylesheet(config.highlightJsCssDark);
94 }
95 if (lightThemeSnippets.length > 0) {
96 loadStylesheet(config.highlightJsCssLight);
97 }
98
99 // Load Highlight.js
100 loadScript(config.highlightJsUrl, function(success) {
101 highlightJsLoading = false;
102 if (success && window.hljs) {
103 highlightJsLoaded = true;
104 console.log('BetterDocs Code Snippet: Highlight.js loaded successfully');
105 } else {
106 console.warn('BetterDocs Code Snippet: Failed to load Highlight.js');
107 }
108 if (callback) callback(highlightJsLoaded);
109 });
110 }
111
112 /**
113 * Apply syntax highlighting to code blocks
114 */
115 function applySyntaxHighlighting() {
116 if (!highlightJsLoaded || !window.hljs) {
117 return;
118 }
119
120 // "cURL" is a first-class language choice but highlight.js has no curl
121 // grammar — alias it to bash so `language-curl` still highlights.
122 try {
123 if (window.hljs.registerAliases) {
124 window.hljs.registerAliases('curl', { languageName: 'bash' });
125 }
126 } catch (e) {}
127
128 // Highlight the inner <code>, not the <pre>. hljs replaces the innerHTML
129 // of whatever it is given — pointing it at the <pre> deletes the <code>
130 // the copy button reads from, which silently broke copying.
131 const codeBlocks = document.querySelectorAll('.betterdocs-code-snippet-code > code:not(.hljs)');
132
133 codeBlocks.forEach(function(block) {
134 try {
135 window.hljs.highlightElement(block);
136 } catch (error) {
137 console.warn('BetterDocs Code Snippet: Error highlighting code block:', error);
138 }
139 });
140 }
141
142 /**
143 * Copy text to clipboard using modern Clipboard API with fallback
144 */
145 function copyToClipboard(text, callback) {
146 // Modern Clipboard API
147 if (navigator.clipboard && window.isSecureContext) {
148 navigator.clipboard.writeText(text).then(function() {
149 if (callback) callback(true);
150 }).catch(function(error) {
151 console.warn('BetterDocs Code Snippet: Clipboard API failed:', error);
152 fallbackCopyToClipboard(text, callback);
153 });
154 } else {
155 fallbackCopyToClipboard(text, callback);
156 }
157 }
158
159 /**
160 * Fallback copy method for older browsers
161 */
162 function fallbackCopyToClipboard(text, callback) {
163 const textArea = document.createElement('textarea');
164 textArea.value = text;
165 textArea.style.position = 'fixed';
166 textArea.style.left = '-999999px';
167 textArea.style.top = '-999999px';
168 document.body.appendChild(textArea);
169
170 try {
171 textArea.focus();
172 textArea.select();
173 const successful = document.execCommand('copy');
174 if (callback) callback(successful);
175 } catch (error) {
176 console.warn('BetterDocs Code Snippet: Fallback copy failed:', error);
177 if (callback) callback(false);
178 } finally {
179 document.body.removeChild(textArea);
180 }
181 }
182
183 /**
184 * The tooltip bubble that belongs to a copy button, if one is rendered.
185 */
186 function getTooltip(button) {
187 const container = button.closest('.betterdocs-code-snippet-copy-container');
188 return container ? container.querySelector('.betterdocs-code-snippet-tooltip') : null;
189 }
190
191 /**
192 * Flip the button into a transient state ("copied" / "error") and back.
193 * Purely class + text driven so repeated clicks can't corrupt the markup —
194 * the previous implementation swapped `svg.outerHTML` and would bake the
195 * tick in permanently if you clicked twice inside its restore window.
196 */
197 function flashState(button, state, label, duration) {
198 const tooltip = getTooltip(button);
199
200 clearTimeout(button._betterDocsResetTimer);
201
202 button.classList.remove('is-copied', 'is-error');
203 button.classList.add(state);
204
205 if (tooltip) {
206 tooltip.textContent = label;
207 tooltip.classList.add('show', state);
208 }
209
210 button._betterDocsResetTimer = setTimeout(function() {
211 button.classList.remove('is-copied', 'is-error');
212 if (tooltip) {
213 tooltip.textContent = tooltip.dataset.copyLabel || 'Copy';
214 tooltip.classList.remove('is-copied', 'is-error');
215 // Only keep it visible while the pointer is still on the button.
216 if (!button.matches(':hover') && !button.matches(':focus-visible')) {
217 tooltip.classList.remove('show');
218 }
219 }
220 }, duration);
221 }
222
223 function showCopySuccess(button) {
224 const tooltip = getTooltip(button);
225 flashState(
226 button,
227 'is-copied',
228 tooltip ? (tooltip.dataset.copiedLabel || 'Copied!') : '',
229 config.copySuccessTimeout
230 );
231 }
232
233 function showCopyError(button) {
234 const tooltip = getTooltip(button);
235 flashState(
236 button,
237 'is-error',
238 tooltip ? (tooltip.dataset.errorLabel || 'Copy failed') : '',
239 config.copySuccessTimeout
240 );
241 }
242
243 /**
244 * Show/hide the tooltip on hover + keyboard focus. Placement is pure CSS
245 * (absolute, anchored to the container), so there is nothing to reposition
246 * on scroll or resize.
247 */
248 function initTooltip(button) {
249 const tooltip = getTooltip(button);
250 if (!tooltip) return;
251
252 const show = function() {
253 tooltip.classList.add('show');
254 };
255 const hide = function() {
256 // Don't yank a "Copied!" confirmation away the moment the pointer leaves.
257 if (button.classList.contains('is-copied') || button.classList.contains('is-error')) {
258 return;
259 }
260 tooltip.classList.remove('show');
261 };
262
263 button.addEventListener('mouseenter', show);
264 button.addEventListener('focus', show);
265 button.addEventListener('mouseleave', hide);
266 button.addEventListener('blur', hide);
267 }
268
269 /**
270 * The code element of the currently-visible language panel (falls back to
271 * the first for single-language snippets). Highlight.js can replace the
272 * inner <code> with its own markup, so accept the <pre> itself as well —
273 * `textContent` is identical either way.
274 */
275 function getActiveCodeElement(snippet) {
276 const panel = snippet.querySelector('.betterdocs-code-snippet-panel.is-active')
277 || snippet.querySelector('.betterdocs-code-snippet-panel')
278 || snippet;
279
280 return panel.querySelector('.betterdocs-code-snippet-code code')
281 || panel.querySelector('.betterdocs-code-snippet-code');
282 }
283
284 /**
285 * Initialize copy button for a specific snippet
286 */
287 function initCopyButton(snippet) {
288 const copyButton = snippet.querySelector('.betterdocs-code-snippet-copy-button');
289
290 if (!copyButton) {
291 return;
292 }
293
294 // The Code Snippet Tab wrapper also carries `.betterdocs-code-snippet-wrapper`,
295 // so both scripts can reach the same button. Bind once.
296 if (copyButton._betterDocsCopyInitialized) {
297 return;
298 }
299 copyButton._betterDocsCopyInitialized = true;
300
301 // Initialize tooltip functionality
302 initTooltip(copyButton);
303
304 // Create new click handler
305 copyButton._betterDocsClickHandler = function(event) {
306 event.preventDefault();
307
308 // Re-query at click time so the copy tracks the active language.
309 const codeElement = getActiveCodeElement(snippet);
310 const codeText = codeElement ? (codeElement.textContent || codeElement.innerText || '') : '';
311
312 if (!codeText.trim()) {
313 console.warn('BetterDocs Code Snippet: no code to copy');
314 showCopyError(copyButton);
315 return;
316 }
317
318 copyToClipboard(codeText, function(success) {
319 if (success) {
320 showCopySuccess(copyButton);
321
322 // Fire custom event
323 const customEvent = new CustomEvent('betterdocs-code-copied', {
324 detail: {
325 snippet: snippet,
326 code: codeText,
327 language: snippet.dataset.language
328 }
329 });
330 document.dispatchEvent(customEvent);
331 } else {
332 console.error('BetterDocs Code Snippet: Failed to copy code to clipboard');
333 showCopyError(copyButton);
334 }
335 });
336 };
337
338 // Add event listener
339 copyButton.addEventListener('click', copyButton._betterDocsClickHandler);
340 }
341
342 /**
343 * Switch the active language panel of a multi-language snippet.
344 */
345 function setActivePanel(snippet, index) {
346 const panels = snippet.querySelectorAll('.betterdocs-code-snippet-panel');
347 const options = snippet.querySelectorAll('.betterdocs-code-lang-option');
348
349 panels.forEach(function(panel) {
350 const isActive = String(panel.dataset.langIndex) === String(index);
351 panel.classList.toggle('is-active', isActive);
352 if (isActive) {
353 panel.removeAttribute('hidden');
354 const code = panel.querySelector('.betterdocs-code-snippet-code');
355 if (code) {
356 snippet.dataset.language = (code.className.match(/language-([\w-]+)/) || [])[1] || snippet.dataset.language;
357 }
358 } else {
359 panel.setAttribute('hidden', '');
360 }
361 });
362
363 options.forEach(function(option) {
364 option.classList.toggle('is-active', String(option.dataset.langIndex) === String(index));
365 });
366 }
367
368 /**
369 * Wire the Mintlify-style language dropdown for a multi-language snippet.
370 */
371 function initLanguageDropdown(snippet) {
372 const dropdown = snippet.querySelector('.betterdocs-code-lang-dropdown');
373 if (!dropdown || dropdown._betterDocsInitialized) {
374 return;
375 }
376 dropdown._betterDocsInitialized = true;
377
378 const toggle = dropdown.querySelector('.betterdocs-code-lang-toggle');
379 const current = dropdown.querySelector('.betterdocs-code-lang-current');
380 const options = dropdown.querySelectorAll('.betterdocs-code-lang-option');
381
382 const close = function() {
383 dropdown.classList.remove('is-open');
384 if (toggle) toggle.setAttribute('aria-expanded', 'false');
385 };
386
387 if (toggle) {
388 toggle.addEventListener('click', function(event) {
389 event.stopPropagation();
390 const willOpen = !dropdown.classList.contains('is-open');
391 dropdown.classList.toggle('is-open', willOpen);
392 toggle.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
393 });
394 }
395
396 options.forEach(function(option) {
397 option.addEventListener('click', function() {
398 const index = option.dataset.langIndex;
399 setActivePanel(snippet, index);
400 // Mirror the chosen option (icon + label) into the toggle.
401 if (current) {
402 current.innerHTML = option.innerHTML;
403 }
404 close();
405 });
406 });
407
408 // Close on outside click / Escape.
409 document.addEventListener('click', function(event) {
410 if (!dropdown.contains(event.target)) {
411 close();
412 }
413 });
414 dropdown.addEventListener('keydown', function(event) {
415 if (event.key === 'Escape') close();
416 });
417 }
418
419 /**
420 * Initialize all code snippets on the page
421 */
422 function initializeCodeSnippets() {
423 const snippets = document.querySelectorAll('.betterdocs-code-snippet-wrapper');
424
425 if (snippets.length === 0) {
426 return;
427 }
428
429 // Load Highlight.js if needed
430 loadHighlightJs(function(loaded) {
431 if (loaded) {
432 applySyntaxHighlighting();
433 }
434 });
435
436 // Initialize copy buttons + language dropdowns
437 snippets.forEach(function(snippet) {
438 const hasCopyButton = snippet.dataset.copyButton === 'true';
439 if (hasCopyButton) {
440 initCopyButton(snippet);
441 }
442 if (snippet.dataset.multiLang === 'true') {
443 initLanguageDropdown(snippet);
444 }
445 });
446
447 copyButtonsInitialized = true;
448 }
449
450 /**
451 * Re-initialize code snippets (useful for dynamic content)
452 */
453 function reinitialize() {
454 copyButtonsInitialized = false;
455 initializeCodeSnippets();
456 }
457
458 /**
459 * Public API
460 */
461 window.BetterDocsCodeSnippet = {
462 init: initializeCodeSnippets,
463 reinit: reinitialize,
464 initCopyButton: initCopyButton,
465 applySyntaxHighlighting: applySyntaxHighlighting,
466 loadHighlightJs: loadHighlightJs
467 };
468
469 // Auto-initialize when DOM is ready
470 if (document.readyState === 'loading') {
471 document.addEventListener('DOMContentLoaded', initializeCodeSnippets);
472 } else {
473 initializeCodeSnippets();
474 }
475
476 // Re-initialize on dynamic content changes (for AJAX-loaded content)
477 if (window.MutationObserver) {
478 const observer = new MutationObserver(function(mutations) {
479 let shouldReinit = false;
480
481 mutations.forEach(function(mutation) {
482 if (mutation.type === 'childList') {
483 mutation.addedNodes.forEach(function(node) {
484 if (node.nodeType === 1) { // Element node
485 if (node.classList && node.classList.contains('betterdocs-code-snippet-wrapper')) {
486 shouldReinit = true;
487 } else if (node.querySelector && node.querySelector('.betterdocs-code-snippet-wrapper')) {
488 shouldReinit = true;
489 }
490 }
491 });
492 }
493 });
494
495 if (shouldReinit) {
496 setTimeout(reinitialize, 100); // Small delay to ensure DOM is stable
497 }
498 });
499
500 observer.observe(document.body, {
501 childList: true,
502 subtree: true
503 });
504 }
505
506 })();
507