1
0
mirror of https://github.com/meineerde/redmine.git synced 2026-01-31 11:37:14 +00:00

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
This commit is contained in:
Go MAEDA 2025-04-17 06:52:50 +00:00
parent 113d7f50a9
commit 38730e5b3c
9 changed files with 178 additions and 20 deletions

View File

@ -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"/>

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -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(); });

View File

@ -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;

View File

@ -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}
)

View File

@ -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 }) %>

View File

@ -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 %>

View File

@ -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 %>

View File

@ -220,3 +220,5 @@
svg: eye
- name: unwatch
svg: eye-off
- name: copy-pre-content
svg: clipboard

View File

@ -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