diff options
-rw-r--r-- | app/assets/javascripts/copy_as_gfm.js.es6 | 319 | ||||
-rw-r--r-- | app/assets/javascripts/shortcuts_issuable.js | 48 | ||||
-rw-r--r-- | changelogs/unreleased/copy-as-md.yml | 4 | ||||
-rw-r--r-- | lib/banzai/filter/abstract_reference_filter.rb | 5 | ||||
-rw-r--r-- | lib/banzai/filter/issue_reference_filter.rb | 2 | ||||
-rw-r--r-- | lib/banzai/filter/syntax_highlight_filter.rb | 5 | ||||
-rw-r--r-- | lib/banzai/filter/video_link_filter.rb | 3 | ||||
-rw-r--r-- | lib/banzai/pipeline/gfm_pipeline.rb | 3 | ||||
-rw-r--r-- | spec/features/copy_as_gfm_spec.rb | 234 |
9 files changed, 595 insertions, 28 deletions
diff --git a/app/assets/javascripts/copy_as_gfm.js.es6 b/app/assets/javascripts/copy_as_gfm.js.es6 new file mode 100644 index 00000000000..4827f7b7420 --- /dev/null +++ b/app/assets/javascripts/copy_as_gfm.js.es6 @@ -0,0 +1,319 @@ +/* eslint-disable class-methods-use-this */ + +(() => { + const gfmRules = { + // Should have an entry for every filter in lib/banzai/pipeline/gfm_pipeline.rb, + // in reverse order. + // Should have test coverage in spec/features/copy_as_gfm_spec.rb. + "InlineDiffFilter": { + "span.idiff.addition": function(el, text) { + return "{+" + text + "+}"; + }, + "span.idiff.deletion": function(el, text) { + return "{-" + text + "-}"; + }, + }, + "TaskListFilter": { + "input[type=checkbox].task-list-item-checkbox": function(el, text) { + return '[' + (el.checked ? 'x' : ' ') + ']'; + } + }, + "ReferenceFilter": { + "a.gfm:not([data-link=true])": function(el, text) { + return el.getAttribute('data-original') || text; + }, + }, + "AutolinkFilter": { + "a": function(el, text) { + if (text != el.getAttribute("href")) { + // Fall back to handler for MarkdownFilter + return false; + } + + return text; + }, + }, + "TableOfContentsFilter": { + "ul.section-nav": function(el, text) { + return "[[_TOC_]]"; + }, + }, + "EmojiFilter": { + "img.emoji": function(el, text) { + return el.getAttribute("alt"); + }, + }, + "ImageLinkFilter": { + "a.no-attachment-icon": function(el, text) { + return text; + }, + }, + "VideoLinkFilter": { + ".video-container": function(el, text) { + var videoEl = el.querySelector('video'); + if (!videoEl) { + return false; + } + + return CopyAsGFM.nodeToGFM(videoEl); + }, + "video": function(el, text) { + return "![" + el.getAttribute('data-title') + "](" + el.getAttribute("src") + ")"; + }, + }, + "MathFilter": { + "pre.code.math[data-math-style='display']": function(el, text) { + return "```math\n" + text.trim() + "\n```"; + }, + "code.code.math[data-math-style='inline']": function(el, text) { + return "$`" + text + "`$"; + }, + "span.katex-display span.katex-mathml": function(el, text) { + var mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) { + return false; + } + + return "```math\n" + CopyAsGFM.nodeToGFM(mathAnnotation) + "\n```"; + }, + "span.katex-mathml": function(el, text) { + var mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) { + return false; + } + + return "$`" + CopyAsGFM.nodeToGFM(mathAnnotation) + "`$"; + }, + "span.katex-html": function(el, text) { + return ""; + }, + 'annotation[encoding="application/x-tex"]': function(el, text) { + return text.trim(); + } + }, + "SyntaxHighlightFilter": { + "pre.code.highlight": function(el, text) { + var lang = el.getAttribute("lang"); + if (lang == "text") { + lang = ""; + } + return "```" + lang + "\n" + text.trim() + "\n```"; + }, + "pre > code": function(el, text) { + // Don't wrap code blocks in `` + return text; + }, + }, + "MarkdownFilter": { + "code": function(el, text) { + var backtickCount = 1; + var backtickMatch = text.match(/`+/); + if (backtickMatch) { + backtickCount = backtickMatch[0].length + 1; + } + + var backticks = new Array(backtickCount + 1).join('`'); + var spaceOrNoSpace = backtickCount > 1 ? " " : ""; + + return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; + }, + "blockquote": function(el, text) { + return text.trim().split('\n').map(function(s) { return ('> ' + s).trim(); }).join('\n'); + }, + "img": function(el, text) { + return "![" + el.getAttribute("alt") + "](" + el.getAttribute("src") + ")"; + }, + "a.anchor": function(el, text) { + return text; + }, + "a": function(el, text) { + return "[" + text + "](" + el.getAttribute("href") + ")"; + }, + "li": function(el, text) { + var lines = text.trim().split('\n'); + var firstLine = '- ' + lines.shift(); + var nextLines = lines.map(function(s) { return (' ' + s).replace(/\s+$/, ''); }); + + return firstLine + '\n' + nextLines.join('\n'); + }, + "ul": function(el, text) { + return text; + }, + "ol": function(el, text) { + return text.replace(/^- /mg, '1. '); + }, + "h1": function(el, text) { + return '# ' + text.trim(); + }, + "h2": function(el, text) { + return '## ' + text.trim(); + }, + "h3": function(el, text) { + return '### ' + text.trim(); + }, + "h4": function(el, text) { + return '#### ' + text.trim(); + }, + "h5": function(el, text) { + return '##### ' + text.trim(); + }, + "h6": function(el, text) { + return '###### ' + text.trim(); + }, + "strong": function(el, text) { + return '**' + text + '**'; + }, + "em": function(el, text) { + return '_' + text + '_'; + }, + "del": function(el, text) { + return '~~' + text + '~~'; + }, + "sup": function(el, text) { + return '^' + text; + }, + "hr": function(el, text) { + return '-----'; + }, + "table": function(el, text) { + var theadText = CopyAsGFM.nodeToGFM(el.querySelector('thead')); + var tbodyText = CopyAsGFM.nodeToGFM(el.querySelector('tbody')); + + return theadText + tbodyText; + }, + "thead": function(el, text) { + var cells = _.map(el.querySelectorAll('th'), function(cell) { + var chars = CopyAsGFM.nodeToGFM(cell).trim().length; + + var before = ''; + var after = ''; + switch (cell.style.textAlign) { + case 'center': + before = ':'; + after = ':'; + chars -= 2; + break; + case 'right': + after = ':'; + chars -= 1; + break; + } + + chars = Math.max(chars, 0); + + var middle = new Array(chars + 1).join('-'); + + return before + middle + after; + }); + return text + '| ' + cells.join(' | ') + ' |'; + }, + "tr": function(el, text) { + var cells = _.map(el.querySelectorAll('td, th'), function(cell) { + return CopyAsGFM.nodeToGFM(cell).trim(); + }); + return '| ' + cells.join(' | ') + ' |'; + }, + } + }; + + class CopyAsGFM { + constructor() { + $(document).on('copy', '.md, .wiki', this.handleCopy.bind(this)); + $(document).on('paste', '.js-gfm-input', this.handlePaste.bind(this)); + } + + handleCopy(e) { + var clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + if (!window.getSelection) return; + + var selection = window.getSelection(); + if (!selection.rangeCount || selection.rangeCount === 0) return; + + var selectedDocument = selection.getRangeAt(0).cloneContents(); + if (!selectedDocument) return; + + e.preventDefault(); + clipboardData.setData('text/plain', selectedDocument.textContent); + + var gfm = CopyAsGFM.nodeToGFM(selectedDocument); + clipboardData.setData('text/x-gfm', gfm); + } + + handlePaste(e) { + var clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + var gfm = clipboardData.getData('text/x-gfm'); + if (!gfm) return; + + e.preventDefault(); + + this.insertText(e.target, gfm); + } + + insertText(target, text) { + // Firefox doesn't support `document.execCommand('insertText', false, text);` on textareas + var selectionStart = target.selectionStart; + var selectionEnd = target.selectionEnd; + var value = target.value; + var textBefore = value.substring(0, selectionStart); + var textAfter = value.substring(selectionEnd, value.length); + var newText = textBefore + text + textAfter; + target.value = newText; + target.selectionStart = target.selectionEnd = selectionStart + text.length; + } + + static nodeToGFM(node) { + if (node.nodeType == Node.TEXT_NODE) { + return node.textContent; + } + + var text = this.innerGFM(node); + + if (node.nodeType == Node.DOCUMENT_FRAGMENT_NODE) { + return text; + } + + for (var filter in gfmRules) { + var rules = gfmRules[filter]; + + for (var selector in rules) { + var func = rules[selector]; + + if (!node.matches(selector)) continue; + + var result = func(node, text); + if (result === false) continue; + + return result; + } + } + + return text; + } + + static innerGFM(parentNode) { + var nodes = parentNode.childNodes; + + var clonedParentNode = parentNode.cloneNode(true); + var clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); + + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var clonedNode = clonedNodes[i]; + + var text = this.nodeToGFM(node); + clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); + } + + return clonedParentNode.innerText || clonedParentNode.textContent; + } + } + + window.gl = window.gl || {}; + window.gl.CopyAsGFM = CopyAsGFM; + + new CopyAsGFM(); +})(); diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index b892fbc3393..426821873d7 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -39,29 +39,33 @@ } ShortcutsIssuable.prototype.replyWithSelectedText = function() { - var quote, replyField, selected, separator; - if (window.getSelection) { - selected = window.getSelection().toString(); - replyField = $('.js-main-target-form #note_note'); - if (selected.trim() === "") { - return; - } - // Put a '>' character before each non-empty line in the selection - quote = _.map(selected.split("\n"), function(val) { - if (val.trim() !== '') { - return "> " + val + "\n"; - } - }); - // If replyField already has some content, add a newline before our quote - separator = replyField.val().trim() !== "" && "\n" || ''; - replyField.val(function(_, current) { - return current + separator + quote.join('') + "\n"; - }); - // Trigger autosave for the added text - replyField.trigger('input'); - // Focus the input field - return replyField.focus(); + var quote, replyField, selectedDocument, selected, selection, separator; + if (!window.getSelection) return; + + selection = window.getSelection(); + if (!selection.rangeCount || selection.rangeCount === 0) return; + + selectedDocument = selection.getRangeAt(0).cloneContents(); + if (!selectedDocument) return; + + selected = window.gl.CopyAsGFM.nodeToGFM(selectedDocument); + + replyField = $('.js-main-target-form #note_note'); + if (selected.trim() === "") { + return; } + quote = _.map(selected.split("\n"), function(val) { + return "> " + val + "\n"; + }); + // If replyField already has some content, add a newline before our quote + separator = replyField.val().trim() !== "" && "\n" || ''; + replyField.val(function(_, current) { + return current + separator + quote.join('') + "\n"; + }); + // Trigger autosave for the added text + replyField.trigger('input'); + // Focus the input field + return replyField.focus(); }; ShortcutsIssuable.prototype.editIssue = function() { diff --git a/changelogs/unreleased/copy-as-md.yml b/changelogs/unreleased/copy-as-md.yml new file mode 100644 index 00000000000..637e9dc36e2 --- /dev/null +++ b/changelogs/unreleased/copy-as-md.yml @@ -0,0 +1,4 @@ +--- +title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM +merge_request: +author: diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index 6d04f68c8f9..a3d495a5da0 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -153,7 +153,7 @@ module Banzai title = object_link_title(object) 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] url = matches[:url] @@ -172,9 +172,10 @@ module Banzai end end - def data_attributes_for(text, project, object) + def data_attributes_for(text, project, object, link: false) data_attribute( original: text, + link: link, project: project.id, object_sym => object.id ) diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 4d1bc687696..fd6b9704132 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -62,7 +62,7 @@ module Banzai end end - def data_attributes_for(text, project, object) + def data_attributes_for(text, project, object, link: false) if object.is_a?(ExternalIssue) data_attribute( project: project.id, diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index 026b81ac175..933103abb92 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -20,17 +20,18 @@ module Banzai code = node.text css_classes = "code highlight" lexer = lexer_for(language) + lang = lexer.tag begin code = format(lex(lexer, code)) - css_classes << " js-syntax-highlight #{lexer.tag}" + css_classes << " js-syntax-highlight #{lang}" rescue # Gracefully handle syntax highlighter bugs/errors to ensure # users can still access an issue/comment/etc. 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 replace_parent_pre_element(node, highlighted) diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb index ac7bbcb0d10..b64a1287d4d 100644 --- a/lib/banzai/filter/video_link_filter.rb +++ b/lib/banzai/filter/video_link_filter.rb @@ -35,7 +35,8 @@ module Banzai src: element['src'], width: '400', controls: true, - 'data-setup' => '{}') + 'data-setup' => '{}', + 'data-title' => element['title'] || element['alt']) link = doc.document.create_element( 'a', diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index 5a1f873496c..09038d38b1f 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -1,6 +1,9 @@ module Banzai module Pipeline class GfmPipeline < BasePipeline + # Every filter should have an entry in app/assets/javascripts/copy_as_gfm.js.es6, + # in reverse order. + # Should have test coverage in spec/features/copy_as_gfm_spec.rb. def self.filters @filters ||= FilterArray[ Filter::SyntaxHighlightFilter, diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb new file mode 100644 index 00000000000..8e8c0ecb282 --- /dev/null +++ b/spec/features/copy_as_gfm_spec.rb @@ -0,0 +1,234 @@ +require 'spec_helper' + +describe 'Copy as GFM', feature: true, js: true do + include GitlabMarkdownHelper + include ActionView::Helpers::JavaScriptHelper + + before do + @feat = MarkdownFeature.new + + # `markdown` helper expects a `@project` variable + @project = @feat.project + + visit namespace_project_issue_path(@project.namespace, @project, @feat.issue) + end + + # Should have an entry for every filter in lib/banzai/pipeline/gfm_pipeline.rb + # and app/assets/javascripts/copy_as_gfm.js.es6 + filters = { + 'any filter' => [ + [ + 'crazy nesting', + '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + ], + [ + 'real world example from the gitlab-ce README', + <<-GFM.strip_heredoc + # GitLab + + [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) + + ## Canonical source + + The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). + + ## Open source software to collaborate on code + + To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). + + + - Manage Git repositories with fine grained access controls that keep your code secure + + - Perform code reviews and enhance collaboration with merge requests + + - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + + - Each project can also have an issue tracker, issue board, and a wiki + + - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + + - Completely free and open source (MIT Expat license) + GFM + ] + ], + 'InlineDiffFilter' => [ + '{-Deleted text-}', + '{+Added text+}' + ], + 'TaskListFilter' => [ + '- [ ] Unchecked task', + '- [x] Checked task', + '1. [ ] Unchecked numbered task', + '1. [x] Checked numbered task' + ], + 'ReferenceFilter' => [ + ['issue reference', -> { @feat.issue.to_reference }], + ['full issue reference', -> { @feat.issue.to_reference(full: true) }], + ['issue URL', -> { namespace_project_issue_url(@project.namespace, @project, @feat.issue) }], + ['issue URL with note anchor', -> { namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123') }], + ['issue link', -> { "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})" }], + ['issue link with note anchor', -> { "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})" }], + ], + 'AutolinkFilter' => [ + 'https://example.com' + ], + 'TableOfContentsFilter' => [ + '[[_TOC_]]' + ], + 'EmojiFilter' => [ + ':thumbsup:' + ], + 'ImageLinkFilter' => [ + '![Image](https://example.com/image.png)' + ], + 'VideoLinkFilter' => [ + '![Video](https://example.com/video.mp4)' + ], + 'MathFilter' => [ + '$`c = \pm\sqrt{a^2 + b^2}`$', + [ + 'math block', + <<-GFM.strip_heredoc + ```math + c = \pm\sqrt{a^2 + b^2} + ``` + GFM + ] + ], + 'SyntaxHighlightFilter' => [ + [ + 'code block', + <<-GFM.strip_heredoc + ```ruby + def foo + bar + end + ``` + GFM + ] + ], + 'MarkdownFilter' => [ + '`code`', + '`` code with ` ticks ``', + + '> Quote', + [ + 'multiline quote', + <<-GFM.strip_heredoc, + > Multiline + > Quote + > + > With multiple paragraphs + GFM + ], + + '![Image](https://example.com/image.png)', + + '# Heading with no anchor link', + + '[Link](https://example.com)', + + '- List item', + [ + 'multiline list item', + <<-GFM.strip_heredoc, + - Multiline + List item + GFM + ], + [ + 'nested lists', + <<-GFM.strip_heredoc, + - Nested + + + - Lists + GFM + ], + '1. Numbered list item', + [ + 'multiline numbered list item', + <<-GFM.strip_heredoc, + 1. Multiline + Numbered list item + GFM + ], + [ + 'nested numbered list', + <<-GFM.strip_heredoc, + 1. Nested + + + 1. Numbered lists + GFM + ], + + '# Heading', + '## Heading', + '### Heading', + '#### Heading', + '##### Heading', + '###### Heading', + + '**Bold**', + + '_Italics_', + + '~~Strikethrough~~', + + '2^2', + + '-----', + + [ + 'table', + <<-GFM.strip_heredoc, + | Centered | Right | Left | + | :------: | ----: | ---- | + | Foo | Bar | **Baz** | + | Foo | Bar | **Baz** | + GFM + ] + ] + } + + filters.each do |filter, examples| + context filter do + examples.each do |ex| + if ex.is_a?(String) + desc = "'#{ex}'" + gfm = ex + else + desc, gfm = ex + end + + it "transforms #{desc} to HTML and back to GFM" do + gfm = instance_exec(&gfm) if gfm.is_a?(Proc) + + html = markdown(gfm) + gfm2 = html_to_gfm(html) + expect(gfm2.strip).to eq(gfm.strip) + end + end + end + end + + 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 + + # Fake a `current_user` helper + def current_user + @feat.user + end +end |