diff options
author | Go MAEDA <maeda@farend.jp> | 2025-04-17 06:52:50 +0000 |
---|---|---|
committer | Go MAEDA <maeda@farend.jp> | 2025-04-17 06:52:50 +0000 |
commit | 38730e5b3c13950a37f79983f67d4a5a9092f09a (patch) | |
tree | 0ea7af4d8fd198754a5a0f36611e61a6f507b4c6 | |
parent | 113d7f50a97314ef833debffd260e825f14a94ca (diff) | |
download | redmine-38730e5b3c13950a37f79983f67d4a5a9092f09a.tar.gz redmine-38730e5b3c13950a37f79983f67d4a5a9092f09a.zip |
Add a button to copy `pre` code block content to the clipboard (#29214).
Patch by Mizuki ISHIKAWA (user:ishikawa999) and Katsuya HIDAKA (user:hidakatsuya).
git-svn-id: https://svn.redmine.org/redmine/trunk@23663 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r-- | app/assets/images/icons.svg | 4 | ||||
-rw-r--r-- | app/assets/javascripts/application.js | 86 | ||||
-rw-r--r-- | app/assets/stylesheets/application.css | 20 | ||||
-rw-r--r-- | app/helpers/application_helper.rb | 10 | ||||
-rw-r--r-- | app/views/journals/update.js.erb | 2 | ||||
-rw-r--r-- | app/views/layouts/base.html.erb | 2 | ||||
-rw-r--r-- | app/views/queries/_filters.html.erb | 1 | ||||
-rw-r--r-- | config/icon_source.yml | 2 | ||||
-rw-r--r-- | test/system/copy_pre_content_to_clipboard_test.rb | 71 |
9 files changed, 178 insertions, 20 deletions
diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg index df09ffd6e..55e3a042d 100644 --- a/app/assets/images/icons.svg +++ b/app/assets/images/icons.svg @@ -141,6 +141,10 @@ <path d="M13 17v-1a1 1 0 0 1 1 -1h1m3 0h1a1 1 0 0 1 1 1v1m0 3v1a1 1 0 0 1 -1 1h-1m-3 0h-1a1 1 0 0 1 -1 -1v-1"/> <path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/> </symbol> + <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--copy-pre-content"> + <path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/> + <path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/> + </symbol> <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--custom-fields"> <path d="M20 13v-4a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v5a2 2 0 0 0 2 2h6"/> <path d="M15 19l2 2l4 -4"/> diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9054ebec0..265ac39c6 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -69,6 +69,12 @@ function updateSVGIcon(element, icon) { iconElement.setAttribute('href', iconPath.replace(/#.*$/g, "#icon--" + icon)) } +function createSVGIcon(icon) { + const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true); + updateSVGIcon(clonedIcon, icon); + return clonedIcon +} + function collapseAllRowGroups(el) { var tbody = $(el).parents('tbody').first(); tbody.children('tr').each(function(index) { @@ -222,8 +228,7 @@ function buildFilterRow(field, operator, values) { case "list_status": case "list_subprojects": const iconType = values.length > 1 ? 'toggle-minus' : 'toggle-plus'; - const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true); - updateSVGIcon(clonedIcon, iconType); + const iconSvg = createSVGIcon(iconType) tr.find('.values').append( $('<span>', { style: 'display:none;' }).append( @@ -233,7 +238,7 @@ function buildFilterRow(field, operator, values) { name: `v[${field}][]`, }), '\n', - $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon) + $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(iconSvg) ) ); select = tr.find('.values select'); @@ -642,23 +647,65 @@ function randomKey(size) { return key; } -function copyTextToClipboard(target) { - if (target) { - var temp = document.createElement('textarea'); - temp.value = target.getAttribute('data-clipboard-text'); - document.body.appendChild(temp); - temp.select(); - document.execCommand('copy'); - if (temp.parentNode) { - temp.parentNode.removeChild(temp); - } - if ($(target).closest('.drdn.expanded').length) { - $(target).closest('.drdn.expanded').removeClass("expanded"); - } +function copyToClipboard(text) { + if (navigator.clipboard) { + return navigator.clipboard.writeText(text).catch(() => { + return fallbackClipboardCopy(text); + }); + } else { + return fallbackClipboardCopy(text); + } +} + +function fallbackClipboardCopy(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(); +} + +function copyDataClipboardTextToClipboard(target) { + copyToClipboard(target.getAttribute('data-clipboard-text')); + + if ($(target).closest('.drdn.expanded').length) { + $(target).closest('.drdn.expanded').removeClass("expanded"); } return false; } +function setupCopyButtonsToPreElements() { + document.querySelectorAll('pre:not(.pre-wrapper pre)').forEach((pre) => { + // Wrap the <pre> element with a container and add a copy button + const wrapper = document.createElement("div"); + wrapper.classList.add("pre-wrapper"); + + const copyButton = document.createElement("a"); + copyButton.title = rm.I18n.buttonCopy; + copyButton.classList.add("copy-pre-content-link", "icon-only"); + copyButton.append(createSVGIcon("copy-pre-content")); + + wrapper.appendChild(copyButton); + wrapper.append(pre.cloneNode(true)); + pre.replaceWith(wrapper); + + // Copy the contents of the pre tag when copyButton is clicked + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + let textToCopy = (pre.querySelector("code") || pre).textContent.replace(/\n$/, ''); + if (pre.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code + copyToClipboard(textToCopy).then(() => { + updateSVGIcon(copyButton, "checked"); + setTimeout(() => updateSVGIcon(copyButton, "copy-pre-content"), 2000); + }); + }); + }); +} + function updateIssueFrom(url, el) { $('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){ $(this).data('valuebeforeupdate', $(this).val()); @@ -1175,7 +1222,7 @@ function setupWikiTableSortableHeader() { }); } -$(function () { +function setupHoverTooltips() { $("[title]:not(.no-tooltip)").tooltip({ show: { delay: 400 @@ -1185,7 +1232,9 @@ $(function () { at: "center top" } }); -}); +} + +$(function() { setupHoverTooltips(); }); function inlineAutoComplete(element) { 'use strict'; @@ -1379,3 +1428,4 @@ $(document).ready(setupWikiTableSortableHeader); $(document).on('focus', '[data-auto-complete=true]', function(event) { inlineAutoComplete(event.target); }); +document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); }); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 85d5deb0a..1b77b95b5 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1540,6 +1540,10 @@ div.wiki ul, div.wiki ol {margin-bottom:1em;} div.wiki li {line-height: 1.6; margin-bottom: 0.125rem;} div.wiki li>ul, div.wiki li>ol {margin-bottom: 0;} +div.wiki div.pre-wrapper { + position: relative; +} + div.wiki pre { margin: 1em 1em 1em 1.6em; padding: 8px; @@ -1557,6 +1561,22 @@ div.wiki *:not(pre)>code, div.wiki>code { border-radius: 0.1em; } +div.pre-wrapper a.copy-pre-content-link { + position: absolute; + top: 3px; + right: calc(1em + 3px); + cursor: pointer; + display: none; + border-radius: 3px; + background: #fff; + border: 1px solid #ccc; + padding: 2px; +} + +div.pre-wrapper:hover a.copy-pre-content-link { + display: block; +} + div.wiki ul.toc { background-color: #ffffdd; border: 1px solid #e4e4e4; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index aa12831e6..2cc704be8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1917,6 +1917,14 @@ module ApplicationHelper end end + def heads_for_i18n + javascript_tag( + "rm = window.rm || {};" \ + "rm.I18n = rm.I18n || {};" \ + "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});" + ) + end + def heads_for_auto_complete(project) data_sources = autocomplete_data_sources(project) javascript_tag( @@ -1934,7 +1942,7 @@ module ApplicationHelper def copy_object_url_link(url) link_to_function( - sprite_icon('copy-link', l(:button_copy_link)), 'copyTextToClipboard(this);', + sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);', class: 'icon icon-copy-link', data: {'clipboard-text' => url} ) diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb index 227d169fc..1c7da09a1 100644 --- a/app/views/journals/update.js.erb +++ b/app/views/journals/update.js.erb @@ -15,6 +15,8 @@ journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>'); } setupWikiTableSortableHeader(); + setupCopyButtonsToPreElements(); + setupHoverTooltips(); <% end %> <%= call_hook(:view_journals_update_js_bottom, { :journal => @journal }) %> diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 9293e3dd1..cd7e2e66f 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -12,6 +12,7 @@ <%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %> <%= javascript_heads %> <%= heads_for_theme %> +<%= heads_for_i18n %> <%= heads_for_auto_complete(@project) %> <%= call_hook :view_layouts_base_html_head %> <!-- page specific tags --> @@ -129,6 +130,7 @@ <div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div> <div id="ajax-modal" style="display:none;"></div> +<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div> </div> <%= call_hook :view_layouts_base_body_bottom %> diff --git a/app/views/queries/_filters.html.erb b/app/views/queries/_filters.html.erb index a1118f6ab..42756775a 100644 --- a/app/views/queries/_filters.html.erb +++ b/app/views/queries/_filters.html.erb @@ -22,6 +22,5 @@ $(document).ready(function(){ <%= select_tag 'add_filter_select', filters_options_for_select(query), :name => nil %> </div> -<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div> <%= hidden_field_tag 'f[]', '' %> <% include_calendar_headers_tags %> diff --git a/config/icon_source.yml b/config/icon_source.yml index d48944c91..dc1803cdc 100644 --- a/config/icon_source.yml +++ b/config/icon_source.yml @@ -220,3 +220,5 @@ svg: eye - name: unwatch svg: eye-off +- name: copy-pre-content + svg: clipboard
\ No newline at end of file diff --git a/test/system/copy_pre_content_to_clipboard_test.rb b/test/system/copy_pre_content_to_clipboard_test.rb new file mode 100644 index 000000000..32ffd4e3e --- /dev/null +++ b/test/system/copy_pre_content_to_clipboard_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +require_relative '../application_system_test_case' +class CopyPreContentToClipboardSystemTest < ApplicationSystemTestCase + def test_copy_issue_pre_content_to_clipboard_if_common_mark + issue = Issue.find(1) + issue.update(description: "```\ntest\ncommon mark\n```") + assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "test\ncommon mark") + end + + def test_copy_issue_code_content_to_clipboard_if_common_mark + issue = Issue.find(1) + issue.update(description: "```ruby\nputs 'Hello, World.'\ncommon mark\n```") + assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "puts 'Hello, World.'\ncommon mark") + end + + def test_copy_issue_pre_content_to_clipboard_if_textile + issue = Issue.find(1) + issue.update(description: "<pre>\ntest\ntextile\n</pre>") + with_settings text_formatting: :textile do + assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "test\ntextile") + end + end + + def test_copy_issue_code_content_to_clipboard_if_textile + issue = Issue.find(1) + issue.update(description: "<pre><code class=\"ruby\">\nputs 'Hello, World.'\ntextile\n</code></pre>") + with_settings text_formatting: :textile do + assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "puts 'Hello, World.'\ntextile") + end + end + + private + + def modifier_key + modifier = osx? ? 'command' : 'control' + modifier.to_sym + end + + def assert_copied_pre_content_matches(issue_id:, expected_value:) + visit "/issues/#{issue_id}" + # A button appears when hovering over the <pre> tag + find("#issue_description_wiki div.pre-wrapper:first-of-type").hover + assert_selector('#issue_description_wiki div.pre-wrapper:first-of-type .copy-pre-content-link') + + # Copy pre content to Clipboard + find("#issue_description_wiki div.pre-wrapper:first-of-type .copy-pre-content-link").click + + # Paste the value copied to the clipboard into the textarea to get and test + first('.icon-edit').click + find('textarea#issue_notes').set('') + find('textarea#issue_notes').send_keys([modifier_key, 'v']) + assert_equal find('textarea#issue_notes').value, expected_value + end +end |