Commit 68903277 authored by Douwe Maan's avatar Douwe Maan

Copy code as GFM from diffs, blobs and GFM code blocks

parent b7166806
...@@ -118,10 +118,10 @@ const gfmRules = { ...@@ -118,10 +118,10 @@ const gfmRules = {
}, },
SyntaxHighlightFilter: { SyntaxHighlightFilter: {
'pre.code.highlight'(el, t) { 'pre.code.highlight'(el, t) {
const text = t.trim(); const text = t.trimRight();
let lang = el.getAttribute('lang'); let lang = el.getAttribute('lang');
if (lang === 'plaintext') { if (!lang || lang === 'plaintext') {
lang = ''; lang = '';
} }
...@@ -157,7 +157,7 @@ const gfmRules = { ...@@ -157,7 +157,7 @@ const gfmRules = {
const backticks = Array(backtickCount + 1).join('`'); const backticks = Array(backtickCount + 1).join('`');
const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
}, },
'blockquote'(el, text) { 'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
...@@ -273,28 +273,29 @@ const gfmRules = { ...@@ -273,28 +273,29 @@ const gfmRules = {
class CopyAsGFM { class CopyAsGFM {
constructor() { constructor() {
$(document).on('copy', '.md, .wiki', this.handleCopy); $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
$(document).on('paste', '.js-gfm-input', this.handlePaste); $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
$(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this));
} }
handleCopy(e) { copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
const documentFragment = window.gl.utils.getSelectedFragment(); const documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) return; if (!documentFragment) return;
// If the documentFragment contains more than just Markdown, don't copy as GFM. const el = transformer(documentFragment.cloneNode(true));
if (documentFragment.querySelector('.md, .wiki')) return; if (!el) return;
e.preventDefault(); e.preventDefault();
clipboardData.setData('text/plain', documentFragment.textContent); e.stopPropagation();
const gfm = CopyAsGFM.nodeToGFM(documentFragment); clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', gfm); clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el));
} }
handlePaste(e) { pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
...@@ -306,7 +307,54 @@ class CopyAsGFM { ...@@ -306,7 +307,54 @@ class CopyAsGFM {
window.gl.utils.insertText(e.target, gfm); window.gl.utils.insertText(e.target, gfm);
} }
static transformGFMSelection(documentFragment) {
// If the documentFragment contains more than just Markdown, don't copy as GFM.
if (documentFragment.querySelector('.md, .wiki')) return null;
return documentFragment;
}
static transformCodeSelection(documentFragment) {
const lineEls = documentFragment.querySelectorAll('.line');
let codeEl;
if (lineEls.length > 1) {
codeEl = document.createElement('pre');
codeEl.className = 'code highlight';
const lang = lineEls[0].getAttribute('lang');
if (lang) {
codeEl.setAttribute('lang', lang);
}
} else {
codeEl = document.createElement('code');
}
if (lineEls.length > 0) {
for (let i = 0; i < lineEls.length; i += 1) {
const lineEl = lineEls[i];
codeEl.appendChild(lineEl);
codeEl.appendChild(document.createTextNode('\n'));
}
} else {
codeEl.appendChild(documentFragment);
}
return codeEl;
}
static selectionToGFM(documentFragment, transformer) {
const el = transformer(documentFragment.cloneNode(true));
if (!el) return null;
return CopyAsGFM.nodeToGFM(el);
}
static nodeToGFM(node) { static nodeToGFM(node) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
return node.textContent; return node.textContent;
} }
......
---
title: Copy code as GFM from diffs, blobs and GFM code blocks
merge_request:
author:
...@@ -5,8 +5,6 @@ module Banzai ...@@ -5,8 +5,6 @@ module Banzai
# HTML Filter to highlight fenced code blocks # HTML Filter to highlight fenced code blocks
# #
class SyntaxHighlightFilter < HTML::Pipeline::Filter class SyntaxHighlightFilter < HTML::Pipeline::Filter
include Rouge::Plugins::Redcarpet
def call def call
doc.search('pre > code').each do |node| doc.search('pre > code').each do |node|
highlight_node(node) highlight_node(node)
...@@ -23,7 +21,7 @@ module Banzai ...@@ -23,7 +21,7 @@ module Banzai
lang = lexer.tag lang = lexer.tag
begin begin
code = format(lex(lexer, code)) code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, code), tag: lang)
css_classes << " js-syntax-highlight #{lang}" css_classes << " js-syntax-highlight #{lang}"
rescue rescue
...@@ -45,10 +43,6 @@ module Banzai ...@@ -45,10 +43,6 @@ module Banzai
lexer.lex(code) lexer.lex(code)
end end
def format(tokens)
rouge_formatter.format(tokens)
end
def lexer_for(language) def lexer_for(language)
(Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new (Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new
end end
...@@ -57,11 +51,6 @@ module Banzai ...@@ -57,11 +51,6 @@ module Banzai
# Replace the parent `pre` element with the entire highlighted block # Replace the parent `pre` element with the entire highlighted block
node.parent.replace(highlighted) node.parent.replace(highlighted)
end end
# Override Rouge::Plugins::Redcarpet#rouge_formatter
def rouge_formatter(lexer = nil)
@rouge_formatter ||= Rouge::Formatters::HTML.new
end
end end
end end
end end
...@@ -14,7 +14,7 @@ module Gitlab ...@@ -14,7 +14,7 @@ module Gitlab
end end
def initialize(blob_name, blob_content, repository: nil) def initialize(blob_name, blob_content, repository: nil)
@formatter = Rouge::Formatters::HTMLGitlab.new @formatter = Rouge::Formatters::HTMLGitlab
@repository = repository @repository = repository
@blob_name = blob_name @blob_name = blob_name
@blob_content = blob_content @blob_content = blob_content
...@@ -28,7 +28,7 @@ module Gitlab ...@@ -28,7 +28,7 @@ module Gitlab
hl_lexer = self.lexer hl_lexer = self.lexer
end end
@formatter.format(hl_lexer.lex(text, continue: continue)).html_safe @formatter.format(hl_lexer.lex(text, continue: continue), tag: hl_lexer.tag).html_safe
rescue rescue
@formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe
end end
......
...@@ -6,9 +6,10 @@ module Rouge ...@@ -6,9 +6,10 @@ module Rouge
# Creates a new <tt>Rouge::Formatter::HTMLGitlab</tt> instance. # Creates a new <tt>Rouge::Formatter::HTMLGitlab</tt> instance.
# #
# [+linenostart+] The line number for the first line (default: 1). # [+linenostart+] The line number for the first line (default: 1).
def initialize(linenostart: 1) def initialize(linenostart: 1, tag: nil)
@linenostart = linenostart @linenostart = linenostart
@line_number = linenostart @line_number = linenostart
@tag = tag
end end
def stream(tokens, &b) def stream(tokens, &b)
...@@ -17,7 +18,7 @@ module Rouge ...@@ -17,7 +18,7 @@ module Rouge
yield "\n" unless is_first yield "\n" unless is_first
is_first = false is_first = false
yield %(<span id="LC#{@line_number}" class="line">) yield %(<span id="LC#{@line_number}" class="line" lang="#{@tag}">)
line.each { |token, value| yield span(token, value.chomp) } line.each { |token, value| yield span(token, value.chomp) }
yield %(</span>) yield %(</span>)
......
...@@ -2,8 +2,14 @@ require 'spec_helper' ...@@ -2,8 +2,14 @@ require 'spec_helper'
describe 'Copy as GFM', feature: true, js: true do describe 'Copy as GFM', feature: true, js: true do
include GitlabMarkdownHelper include GitlabMarkdownHelper
include RepoHelpers
include ActionView::Helpers::JavaScriptHelper include ActionView::Helpers::JavaScriptHelper
before do
login_as :admin
end
describe 'Copying rendered GFM' do
before do before do
@feat = MarkdownFeature.new @feat = MarkdownFeature.new
...@@ -410,17 +416,6 @@ describe 'Copy as GFM', feature: true, js: true do ...@@ -410,17 +416,6 @@ describe 'Copy as GFM', feature: true, js: true do
alias_method :gfm_to_html, :markdown alias_method :gfm_to_html, :markdown
def html_to_gfm(html)
js = <<-JS.strip_heredoc
(function(html) {
var node = document.createElement('div');
node.innerHTML = html;
return window.gl.CopyAsGFM.nodeToGFM(node);
})("#{escape_javascript(html)}")
JS
page.evaluate_script(js)
end
def verify(label, *gfms) def verify(label, *gfms)
aggregate_failures(label) do aggregate_failures(label) do
gfms.each do |gfm| gfms.each do |gfm|
...@@ -435,4 +430,161 @@ describe 'Copy as GFM', feature: true, js: true do ...@@ -435,4 +430,161 @@ describe 'Copy as GFM', feature: true, js: true do
def current_user def current_user
@feat.user @feat.user
end end
end
describe 'Copying code' do
let(:project) { create(:project) }
context 'from a diff' do
before do
visit namespace_project_commit_path(project.namespace, project, sample_commit.id)
end
context 'selecting one word of text' do
it 'copies as inline code' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no',
'`RuntimeError`'
)
end
end
context 'selecting one line of text' do
it 'copies as inline code' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line',
'`raise RuntimeError, "System commands must be given as an array of strings"`'
)
end
end
context 'selecting multiple lines of text' do
it 'copies as a code block' do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
```
GFM
)
end
end
end
context 'from a blob' do
before do
visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb'))
end
context 'selecting one word of text' do
it 'copies as inline code' do
verify(
'.line[id="LC9"] .no',
'`RuntimeError`'
)
end
end
context 'selecting one line of text' do
it 'copies as inline code' do
verify(
'.line[id="LC9"]',
'`raise RuntimeError, "System commands must be given as an array of strings"`'
)
end
end
context 'selecting multiple lines of text' do
it 'copies as a code block' do
verify(
'.line[id="LC9"], .line[id="LC10"]',
<<-GFM.strip_heredoc,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
```
GFM
)
end
end
end
context 'from a GFM code block' do
before do
visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md'))
end
context 'selecting one word of text' do
it 'copies as inline code' do
verify(
'.line[id="LC27"] .s2',
'`"bio"`'
)
end
end
context 'selecting one line of text' do
it 'copies as inline code' do
verify(
'.line[id="LC27"]',
'`"bio": null,`'
)
end
end
context 'selecting multiple lines of text' do
it 'copies as a code block' do
verify(
'.line[id="LC27"], .line[id="LC28"]',
<<-GFM.strip_heredoc,
```json
"bio": null,
"skype": "",
```
GFM
)
end
end
end
def verify(selector, gfm)
html = html_for_selector(selector)
output_gfm = html_to_gfm(html, 'transformCodeSelection');
expect(output_gfm.strip).to eq(gfm.strip)
end
end
def html_for_selector(selector)
js = <<-JS.strip_heredoc
(function(selector) {
var els = document.querySelectorAll(selector);
var htmls = _.map(els, function(el) { return el.outerHTML; });
return htmls.join("\\n");
})("#{escape_javascript(selector)}")
JS
page.evaluate_script(js)
end
def html_to_gfm(html, transformer = 'transformGFMSelection')
js = <<-JS.strip_heredoc
(function(html) {
var node = document.createElement('div');
node.innerHTML = html;
var transformer = window.gl.CopyAsGFM[#{transformer.inspect}];
return window.gl.CopyAsGFM.selectionToGFM(node, transformer);
})("#{escape_javascript(html)}")
JS
page.evaluate_script(js)
end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment