Commit a24e9a0e authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'copy-as-md' into 'master'

Copying a rendered issue/comment will paste into GFM textareas as actual GFM

See merge request !8597
parents 112f9710 6c2d8f35
This diff is collapsed.
...@@ -160,6 +160,62 @@ ...@@ -160,6 +160,62 @@
return decodeURIComponent(results[2].replace(/\+/g, ' ')); return decodeURIComponent(results[2].replace(/\+/g, ' '));
}; };
w.gl.utils.getSelectedFragment = () => {
const selection = window.getSelection();
const documentFragment = selection.getRangeAt(0).cloneContents();
if (documentFragment.textContent.length === 0) return null;
return documentFragment;
};
w.gl.utils.insertText = (target, text) => {
// Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
const selectionStart = target.selectionStart;
const selectionEnd = target.selectionEnd;
const value = target.value;
const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length);
const newText = textBefore + text + textAfter;
target.value = newText;
target.selectionStart = target.selectionEnd = selectionStart + text.length;
// Trigger autosave
$(target).trigger('input');
// Trigger autosize
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
target.dispatchEvent(event);
};
w.gl.utils.nodeMatchesSelector = (node, selector) => {
const matches = Element.prototype.matches ||
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector;
if (matches) {
return matches.call(node, selector);
}
// IE11 doesn't support `node.matches(selector)`
let parentNode = node.parentNode;
if (!parentNode) {
parentNode = document.createElement('div');
node = node.cloneNode(true);
parentNode.appendChild(node);
}
const matchingNodes = parentNode.querySelectorAll(selector);
return Array.prototype.indexOf.call(matchingNodes, node) !== -1;
};
/** /**
this will take in the headers from an API response and normalize them this will take in the headers from an API response and normalize them
this way we don't run into production issues when nginx gives us lowercased header keys this way we don't run into production issues when nginx gives us lowercased header keys
......
...@@ -39,29 +39,39 @@ ...@@ -39,29 +39,39 @@
} }
ShortcutsIssuable.prototype.replyWithSelectedText = function() { ShortcutsIssuable.prototype.replyWithSelectedText = function() {
var quote, replyField, selected, separator; var quote, replyField, documentFragment, selected, separator;
if (window.getSelection) {
selected = window.getSelection().toString(); documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) return;
// If the documentFragment contains more than just Markdown, don't copy as GFM.
if (documentFragment.querySelector('.md, .wiki')) return;
selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
replyField = $('.js-main-target-form #note_note'); replyField = $('.js-main-target-form #note_note');
if (selected.trim() === "") { if (selected.trim() === "") {
return; return;
} }
// Put a '>' character before each non-empty line in the selection
quote = _.map(selected.split("\n"), function(val) { quote = _.map(selected.split("\n"), function(val) {
if (val.trim() !== '') { return ("> " + val).trim() + "\n";
return "> " + val + "\n";
}
}); });
// If replyField already has some content, add a newline before our quote // If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n" || ''; separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(_, current) { replyField.val(function(_, current) {
return current + separator + quote.join('') + "\n"; return current + separator + quote.join('') + "\n";
}); });
// Trigger autosave for the added text
// Trigger autosave
replyField.trigger('input'); replyField.trigger('input');
// Trigger autosize
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
replyField.get(0).dispatchEvent(event);
// Focus the input field // Focus the input field
return replyField.focus(); return replyField.focus();
}
}; };
ShortcutsIssuable.prototype.editIssue = function() { ShortcutsIssuable.prototype.editIssue = function() {
......
---
title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM
merge_request:
author:
...@@ -153,7 +153,7 @@ module Banzai ...@@ -153,7 +153,7 @@ module Banzai
title = object_link_title(object) title = object_link_title(object)
klass = reference_class(object_sym) klass = reference_class(object_sym)
data = data_attributes_for(link_content || match, project, object) data = data_attributes_for(link_content || match, project, object, link: !!link_content)
if matches.names.include?("url") && matches[:url] if matches.names.include?("url") && matches[:url]
url = matches[:url] url = matches[:url]
...@@ -172,9 +172,10 @@ module Banzai ...@@ -172,9 +172,10 @@ module Banzai
end end
end end
def data_attributes_for(text, project, object) def data_attributes_for(text, project, object, link: false)
data_attribute( data_attribute(
original: text, original: text,
link: link,
project: project.id, project: project.id,
object_sym => object.id object_sym => object.id
) )
......
...@@ -62,7 +62,7 @@ module Banzai ...@@ -62,7 +62,7 @@ module Banzai
end end
end end
def data_attributes_for(text, project, object) def data_attributes_for(text, project, object, link: false)
if object.is_a?(ExternalIssue) if object.is_a?(ExternalIssue)
data_attribute( data_attribute(
project: project.id, project: project.id,
......
...@@ -20,17 +20,19 @@ module Banzai ...@@ -20,17 +20,19 @@ module Banzai
code = node.text code = node.text
css_classes = "code highlight" css_classes = "code highlight"
lexer = lexer_for(language) lexer = lexer_for(language)
lang = lexer.tag
begin begin
code = format(lex(lexer, code)) code = format(lex(lexer, code))
css_classes << " js-syntax-highlight #{lexer.tag}" css_classes << " js-syntax-highlight #{lang}"
rescue rescue
lang = nil
# Gracefully handle syntax highlighter bugs/errors to ensure # Gracefully handle syntax highlighter bugs/errors to ensure
# users can still access an issue/comment/etc. # users can still access an issue/comment/etc.
end end
highlighted = %(<pre class="#{css_classes}" v-pre="true"><code>#{code}</code></pre>) highlighted = %(<pre class="#{css_classes}" lang="#{lang}" v-pre="true"><code>#{code}</code></pre>)
# Extracted to a method to measure it # Extracted to a method to measure it
replace_parent_pre_element(node, highlighted) replace_parent_pre_element(node, highlighted)
......
...@@ -35,7 +35,8 @@ module Banzai ...@@ -35,7 +35,8 @@ module Banzai
src: element['src'], src: element['src'],
width: '400', width: '400',
controls: true, controls: true,
'data-setup' => '{}') 'data-setup' => '{}',
'data-title' => element['title'] || element['alt'])
link = doc.document.create_element( link = doc.document.create_element(
'a', 'a',
......
module Banzai module Banzai
module Pipeline module Pipeline
class GfmPipeline < BasePipeline class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML.
# The handlers defined in app/assets/javascripts/copy_as_gfm.js.es6
# consequently convert that same HTML to GFM to be copied to the clipboard.
# Every filter that generates HTML from GFM should have a handler in
# app/assets/javascripts/copy_as_gfm.js.es6, in reverse order.
# The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters def self.filters
@filters ||= FilterArray[ @filters ||= FilterArray[
Filter::SyntaxHighlightFilter, Filter::SyntaxHighlightFilter,
......
This diff is collapsed.
/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */ /* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */ /* global ShortcutsIssuable */
/*= require copy_as_gfm */
/*= require shortcuts_issuable */ /*= require shortcuts_issuable */
(function() { (function() {
...@@ -14,10 +15,12 @@ ...@@ -14,10 +15,12 @@
}); });
return describe('#replyWithSelectedText', function() { return describe('#replyWithSelectedText', function() {
var stubSelection; var stubSelection;
// Stub window.getSelection to return the provided String. // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
stubSelection = function(text) { stubSelection = function(html) {
return window.getSelection = function() { window.gl.utils.getSelectedFragment = function() {
return text; var node = document.createElement('div');
node.innerHTML = html;
return node;
}; };
}; };
beforeEach(function() { beforeEach(function() {
...@@ -32,13 +35,13 @@ ...@@ -32,13 +35,13 @@
}); });
describe('with any selection', function() { describe('with any selection', function() {
beforeEach(function() { beforeEach(function() {
return stubSelection('Selected text.'); return stubSelection('<p>Selected text.</p>');
}); });
it('leaves existing input intact', function() { it('leaves existing input intact', function() {
$(this.selector).val('This text was already here.'); $(this.selector).val('This text was already here.');
expect($(this.selector).val()).toBe('This text was already here.'); expect($(this.selector).val()).toBe('This text was already here.');
this.shortcut.replyWithSelectedText(); this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("This text was already here.\n> Selected text.\n\n"); return expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n");
}); });
it('triggers `input`', function() { it('triggers `input`', function() {
var triggered; var triggered;
...@@ -61,16 +64,16 @@ ...@@ -61,16 +64,16 @@
}); });
describe('with a one-line selection', function() { describe('with a one-line selection', function() {
return it('quotes the selection', function() { return it('quotes the selection', function() {
stubSelection('This text has been selected.'); stubSelection('<p>This text has been selected.</p>');
this.shortcut.replyWithSelectedText(); this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("> This text has been selected.\n\n"); return expect($(this.selector).val()).toBe("> This text has been selected.\n\n");
}); });
}); });
return describe('with a multi-line selection', function() { return describe('with a multi-line selection', function() {
return it('quotes the selected lines as a group', function() { return it('quotes the selected lines as a group', function() {
stubSelection("Selected line one.\n\nSelected line two.\nSelected line three.\n"); stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>");
this.shortcut.replyWithSelectedText(); this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("> Selected line one.\n> Selected line two.\n> Selected line three.\n\n"); return expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
}); });
}); });
}); });
......
...@@ -6,21 +6,21 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do ...@@ -6,21 +6,21 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do
context "when no language is specified" do context "when no language is specified" do
it "highlights as plaintext" do it "highlights as plaintext" do
result = filter('<pre><code>def fun end</code></pre>') result = filter('<pre><code>def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>def fun end</code></pre>') expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>def fun end</code></pre>')
end end
end end
context "when a valid language is specified" do context "when a valid language is specified" do
it "highlights as that language" do it "highlights as that language" do
result = filter('<pre><code class="ruby">def fun end</code></pre>') result = filter('<pre><code class="ruby">def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>') expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" lang="ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>')
end end
end end
context "when an invalid language is specified" do context "when an invalid language is specified" do
it "highlights as plaintext" do it "highlights as plaintext" do
result = filter('<pre><code class="gnuplot">This is a test</code></pre>') result = filter('<pre><code class="gnuplot">This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>This is a test</code></pre>') expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>This is a test</code></pre>')
end end
end end
...@@ -31,7 +31,7 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do ...@@ -31,7 +31,7 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do
it "highlights as plaintext" do it "highlights as plaintext" do
result = filter('<pre><code class="ruby">This is a test</code></pre>') result = filter('<pre><code class="ruby">This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight" v-pre="true"><code>This is a test</code></pre>') expect(result.to_html).to eq('<pre class="code highlight" lang="" v-pre="true"><code>This is a test</code></pre>')
end end
end 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