From da5245ea0653c08486e2336ea102298030f72365 Mon Sep 17 00:00:00 2001 From: Marius Balteanu Date: Fri, 23 Jan 2026 16:14:04 +0000 Subject: [PATCH] Add stimulus clipboard_controller and render copy button on the backend using copypre_scrubber (#43643). Patch by Takashi Kato (user:tohosaku). git-svn-id: https://svn.redmine.org/redmine/trunk@24360 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/assets/javascripts/application-legacy.js | 1 - app/helpers/application_helper.rb | 9 ++- .../controllers/clipboard_controller.js | 55 ++++++++++++++++++ app/views/journals/update.js.erb | 1 - .../wiki_formatting/common_mark/formatter.rb | 1 + .../wiki_formatting/copypre_scrubber.rb | 33 +++++++++++ .../wiki_formatting/textile/formatter.rb | 3 +- test/helpers/application_helper_test.rb | 58 +++++++++++-------- .../redmine/wiki_formatting/macros_test.rb | 20 +++++-- .../wiki_formatting/textile_formatter_test.rb | 29 ++++++---- 10 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 app/javascript/controllers/clipboard_controller.js create mode 100644 lib/redmine/wiki_formatting/copypre_scrubber.rb diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js index 833e7d8d1..9662d2396 100644 --- a/app/assets/javascripts/application-legacy.js +++ b/app/assets/javascripts/application-legacy.js @@ -1441,4 +1441,3 @@ $(document).ready(setupFilePreviewNavigation); $(document).on('focus', '[data-auto-complete=true]', function(event) { inlineAutoComplete(event.target); }); -document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); }); diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index df3bc8ddc..aabb78410 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1923,11 +1923,10 @@ module ApplicationHelper end def copy_object_url_link(url) - link_to_function( - sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);', - class: 'icon icon-copy-link', - data: {'clipboard-text' => url} - ) + link_to sprite_icon('copy-link', l(:button_copy_link)), + '#', + class: 'icon icon-copy-link', + data: {clipboard_text: url, controller: 'clipboard', action: 'clipboard#copyText'} end private diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js new file mode 100644 index 000000000..1ea07c446 --- /dev/null +++ b/app/javascript/controllers/clipboard_controller.js @@ -0,0 +1,55 @@ +/** + * Redmine - project management software + * Copyright (C) 2006- Jean-Philippe Lang + * This code is released under the GNU General Public License. + */ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="clipboard" +export default class extends Controller { + static targets = ['pre']; + + copyPre(e) { + e.preventDefault(); + const element = e.currentTarget; + let textToCopy = (this.preTarget.querySelector("code") || this.preTarget).textContent.replace(/\n$/, ''); + if (this.preTarget.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code + + this.copy(textToCopy).then(() => { + updateSVGIcon(element, "checked"); + setTimeout(() => updateSVGIcon(element, "copy-pre-content"), 2000); + }); + } + + copyText(e) { + e.preventDefault(); + this.copy(e.currentTarget.dataset.clipboardText); + + const element = e.currentTarget.closest('.drdn.expanded'); + if (element !== null) { + element.classList.remove('expanded'); + } + } + + copy(text) { + if (navigator.clipboard) { + return navigator.clipboard.writeText(text).catch(() => { + return this.fallback(text); + }); + } else { + return this.fallback(text); + } + } + + fallback(text) { + const temp = document.createElement('textarea'); + temp.value = text; + temp.style.position = 'fixed'; + temp.style.left = '-9999px'; + document.body.appendChild(temp); + temp.select(); + document.execCommand('copy'); + document.body.removeChild(temp); + return Promise.resolve(); + } +} diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb index 218a164de..cd75cd8fa 100644 --- a/app/views/journals/update.js.erb +++ b/app/views/journals/update.js.erb @@ -14,7 +14,6 @@ } else { journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>'); } - setupCopyButtonsToPreElements(); setupHoverTooltips(); <% end %> diff --git a/lib/redmine/wiki_formatting/common_mark/formatter.rb b/lib/redmine/wiki_formatting/common_mark/formatter.rb index d9d3e9640..9ef52692e 100644 --- a/lib/redmine/wiki_formatting/common_mark/formatter.rb +++ b/lib/redmine/wiki_formatting/common_mark/formatter.rb @@ -56,6 +56,7 @@ module Redmine SCRUBBERS = [ SyntaxHighlightScrubber.new, Redmine::WikiFormatting::TablesortScrubber.new, + Redmine::WikiFormatting::CopypreScrubber.new, FixupAutoLinksScrubber.new, ExternalLinksScrubber.new, AlertsIconsScrubber.new diff --git a/lib/redmine/wiki_formatting/copypre_scrubber.rb b/lib/redmine/wiki_formatting/copypre_scrubber.rb new file mode 100644 index 000000000..2afd02823 --- /dev/null +++ b/lib/redmine/wiki_formatting/copypre_scrubber.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# This code is released under the GNU General Public License. + +module Redmine + module WikiFormatting + class CopypreScrubber < Loofah::Scrubber + def scrub(node) + return unless node.name == 'pre' + + node['data-clipboard-target'] = 'pre' + # Wrap the
 element with a container and add a copy button
+        node.wrap(wrapper)
+
+        # Copy the contents of the pre tag when copyButton is clicked
+        node.parent.prepend_child(button)
+      end
+
+      def wrapper
+        @wrapper ||= Nokogiri::HTML5.fragment('
').children.first + end + + def button + icon = ApplicationController.helpers.sprite_icon('copy-pre-content', size: 18) + button_copy = ApplicationController.helpers.l(:button_copy) + html = '' + icon + '' + @button ||= Nokogiri::HTML5.fragment(html).children.first + end + end + end +end diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb index 51eb5ff01..57d8dbab4 100644 --- a/lib/redmine/wiki_formatting/textile/formatter.rb +++ b/lib/redmine/wiki_formatting/textile/formatter.rb @@ -22,7 +22,8 @@ module Redmine module Textile SCRUBBERS = [ SyntaxHighlightScrubber.new, - Redmine::WikiFormatting::TablesortScrubber.new + Redmine::WikiFormatting::TablesortScrubber.new, + Redmine::WikiFormatting::CopypreScrubber.new ] class Formatter diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 4d0834773..1d0967203 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -1320,20 +1320,20 @@ class ApplicationHelperTest < Redmine::HelperTest "
content
" => "

<div class=\"bold\">content</div>

", "" => "

<script>some script;</script>

", # do not escape pre/code tags - "
\nline 1\nline2
" => "
line 1\nline2
", - "
\nline 1\nline2
" => "
\nline 1\nline2
", - "
content
" => "
<div class=\"foo\">content</div>
", - "
content
" => "
<div class=\"<foo\">content</div>
", + "
\nline 1\nline2
" => pre_wrapper("
line 1\nline2
"), + "
\nline 1\nline2
" => pre_wrapper("
\nline 1\nline2
"), + "
content
" => pre_wrapper("
<div class=\"foo\">content</div>
"), + "
content
" => pre_wrapper("
<div class=\"<foo\">content</div>
"), "