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:
parent
113d7f50a9
commit
38730e5b3c
@ -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 |
@ -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(); });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
)
|
||||
|
||||
@ -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 }) %>
|
||||
|
||||
@ -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 %>
|
||||
|
||||
@ -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 %>
|
||||
|
||||
@ -220,3 +220,5 @@
|
||||
svg: eye
|
||||
- name: unwatch
|
||||
svg: eye-off
|
||||
- name: copy-pre-content
|
||||
svg: clipboard
|
||||
71
test/system/copy_pre_content_to_clipboard_test.rb
Normal file
71
test/system/copy_pre_content_to_clipboard_test.rb
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user