@@ -361,6 +361,104 @@ function hookSearchModal() {
361361 }
362362}
363363
364+ /* ===== Code Block Copy Functionality ===== */
365+
366+ function createCopyButton ( ) {
367+ var button = document . createElement ( 'button' ) ;
368+ button . className = 'copy-code-button' ;
369+ button . type = 'button' ;
370+ button . setAttribute ( 'aria-label' , 'Copy code to clipboard' ) ;
371+ button . setAttribute ( 'title' , 'Copy code' ) ;
372+
373+ // Create clipboard icon SVG
374+ var clipboardIcon = `
375+ <svg viewBox="0 0 24 24">
376+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
377+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
378+ </svg>
379+ ` ;
380+
381+ // Create checkmark icon SVG (for copied state)
382+ var checkIcon = `
383+ <svg viewBox="0 0 24 24">
384+ <polyline points="20 6 9 17 4 12"></polyline>
385+ </svg>
386+ ` ;
387+
388+ button . innerHTML = clipboardIcon ;
389+ button . dataset . clipboardIcon = clipboardIcon ;
390+ button . dataset . checkIcon = checkIcon ;
391+
392+ return button ;
393+ }
394+
395+ function wrapCodeBlocksWithCopyButton ( ) {
396+ // Copy buttons are generated dynamically rather than statically in rhtml templates because:
397+ // - Code blocks are generated by RDoc's markup formatter (RDoc::Markup::ToHtml),
398+ // not directly in rhtml templates
399+ // - Modifying the formatter would require extending RDoc's core internals
400+
401+ // Find all pre elements that are not already wrapped
402+ var preElements = document . querySelectorAll ( 'main pre:not(.code-block-wrapper pre)' ) ;
403+
404+ preElements . forEach ( function ( pre ) {
405+ // Skip if already wrapped
406+ if ( pre . parentElement . classList . contains ( 'code-block-wrapper' ) ) {
407+ return ;
408+ }
409+
410+ // Create wrapper
411+ var wrapper = document . createElement ( 'div' ) ;
412+ wrapper . className = 'code-block-wrapper' ;
413+
414+ // Insert wrapper before pre
415+ pre . parentNode . insertBefore ( wrapper , pre ) ;
416+
417+ // Move pre into wrapper
418+ wrapper . appendChild ( pre ) ;
419+
420+ // Create and add copy button
421+ var copyButton = createCopyButton ( ) ;
422+ wrapper . appendChild ( copyButton ) ;
423+
424+ // Add click handler
425+ copyButton . addEventListener ( 'click' , function ( ) {
426+ copyCodeToClipboard ( pre , copyButton ) ;
427+ } ) ;
428+ } ) ;
429+ }
430+
431+ function copyCodeToClipboard ( preElement , button ) {
432+ var code = preElement . textContent ;
433+
434+ // Use the Clipboard API (supported by all modern browsers)
435+ if ( navigator . clipboard && navigator . clipboard . writeText ) {
436+ navigator . clipboard . writeText ( code ) . then ( function ( ) {
437+ showCopySuccess ( button ) ;
438+ } ) . catch ( function ( ) {
439+ alert ( 'Failed to copy code.' ) ;
440+ } ) ;
441+ } else {
442+ alert ( 'Failed to copy code.' ) ;
443+ }
444+ }
445+
446+ function showCopySuccess ( button ) {
447+ // Change icon to checkmark
448+ button . innerHTML = button . dataset . checkIcon ;
449+ button . classList . add ( 'copied' ) ;
450+ button . setAttribute ( 'aria-label' , 'Copied!' ) ;
451+ button . setAttribute ( 'title' , 'Copied!' ) ;
452+
453+ // Revert back after 2 seconds
454+ setTimeout ( function ( ) {
455+ button . innerHTML = button . dataset . clipboardIcon ;
456+ button . classList . remove ( 'copied' ) ;
457+ button . setAttribute ( 'aria-label' , 'Copy code to clipboard' ) ;
458+ button . setAttribute ( 'title' , 'Copy code' ) ;
459+ } , 2000 ) ;
460+ }
461+
364462/* ===== Initialization ===== */
365463
366464document . addEventListener ( 'DOMContentLoaded' , function ( ) {
@@ -371,4 +469,5 @@ document.addEventListener('DOMContentLoaded', function() {
371469 generateToc ( ) ;
372470 hookTocActiveHighlighting ( ) ;
373471 hookSearchModal ( ) ;
472+ wrapCodeBlocksWithCopyButton ( ) ;
374473} ) ;
0 commit comments