/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ import _ from 'underscore'; import { insertText, getSelectedFragment, nodeMatchesSelector } from '../lib/utils/common_utils'; import { placeholderImage } from '../lazy_loader'; const gfmRules = { // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert // GitLab Flavored Markdown (GFM) to HTML. // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML // from GFM should have a handler here, in reverse order. // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. InlineDiffFilter: { 'span.idiff.addition'(el, text) { return `{+${text}+}`; }, 'span.idiff.deletion'(el, text) { return `{-${text}-}`; }, }, TaskListFilter: { 'input[type=checkbox].task-list-item-checkbox'(el) { return `[${el.checked ? 'x' : ' '}]`; }, }, ReferenceFilter: { '.tooltip'(el) { return ''; }, 'a.gfm:not([data-link=true])'(el, text) { return el.dataset.original || text; }, }, AutolinkFilter: { 'a'(el, text) { // Fallback on the regular MarkdownFilter's `a` handler. if (text !== el.getAttribute('href')) return false; return text; }, }, TableOfContentsFilter: { 'ul.section-nav'(el) { return '[[_TOC_]]'; }, }, EmojiFilter: { 'img.emoji'(el) { return el.getAttribute('alt'); }, 'gl-emoji'(el) { return `:${el.getAttribute('data-name')}:`; }, }, ImageLinkFilter: { 'a.no-attachment-icon'(el, text) { return text; }, }, ImageLazyLoadFilter: { 'img'(el, text) { return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; }, }, VideoLinkFilter: { '.video-container'(el) { const videoEl = el.querySelector('video'); if (!videoEl) return false; return CopyAsGFM.nodeToGFM(videoEl); }, 'video'(el) { return `![${el.dataset.title}](${el.getAttribute('src')})`; }, }, MermaidFilter: { 'svg.mermaid'(el, text) { const sourceEl = el.querySelector('text.source'); if (!sourceEl) return false; return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; }, 'svg.mermaid style, svg.mermaid g'(el, text) { // We don't want to include the content of these elements in the copied text. return ''; }, }, MathFilter: { 'pre.code.math[data-math-style=display]'(el, text) { return `\`\`\`math\n${text.trim()}\n\`\`\``; }, 'code.code.math[data-math-style=inline]'(el, text) { return `$\`${text}\`$`; }, 'span.katex-display span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; }, 'span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; }, 'span.katex-html'(el) { // We don't want to include the content of this element in the copied text. return ''; }, 'annotation[encoding="application/x-tex"]'(el, text) { return text.trim(); }, }, SanitizationFilter: { 'a[name]:not([href]):empty'(el) { return el.outerHTML; }, 'dl'(el, text) { let lines = text.trim().split('\n'); // Add two spaces to the front of subsequent list items lines, // or leave the line entirely blank. lines = lines.map((l) => { const line = l.trim(); if (line.length === 0) return ''; return ` ${line}`; }); return `
\n${lines.join('\n')}\n
`; }, 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { const tag = el.nodeName.toLowerCase(); return `<${tag}>${text}`; }, }, SyntaxHighlightFilter: { 'pre.code.highlight'(el, t) { const text = t.trimRight(); let lang = el.getAttribute('lang'); if (!lang || lang === 'plaintext') { lang = ''; } // Prefixes lines with 4 spaces if the code contains triple backticks if (lang === '' && text.match(/^```/gm)) { return text.split('\n').map((l) => { const line = l.trim(); if (line.length === 0) return ''; return ` ${line}`; }).join('\n'); } return `\`\`\`${lang}\n${text}\n\`\`\``; }, 'pre > code'(el, text) { // Don't wrap code blocks in `` return text; }, }, MarkdownFilter: { 'br'(el) { // Two spaces at the end of a line are turned into a BR return ' '; }, 'code'(el, text) { let backtickCount = 1; const backtickMatch = text.match(/`+/); if (backtickMatch) { backtickCount = backtickMatch[0].length + 1; } const backticks = Array(backtickCount + 1).join('`'); const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; }, 'blockquote'(el, text) { return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); }, 'img'(el) { const imageSrc = el.src; const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || ''); return `![${el.getAttribute('alt')}](${imageUrl})`; }, 'a.anchor'(el, text) { // Don't render a Markdown link for the anchor link inside a heading return text; }, 'a'(el, text) { return `[${text}](${el.getAttribute('href')})`; }, 'li'(el, text) { const lines = text.trim().split('\n'); const firstLine = `- ${lines.shift()}`; // Add four spaces to the front of subsequent list items lines, // or leave the line entirely blank. const nextLines = lines.map((s) => { if (s.trim().length === 0) return ''; return ` ${s}`; }); return `${firstLine}\n${nextLines.join('\n')}`; }, 'ul'(el, text) { return text; }, 'ol'(el, text) { // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. return text.replace(/^- /mg, '1. '); }, 'h1'(el, text) { return `# ${text.trim()}`; }, 'h2'(el, text) { return `## ${text.trim()}`; }, 'h3'(el, text) { return `### ${text.trim()}`; }, 'h4'(el, text) { return `#### ${text.trim()}`; }, 'h5'(el, text) { return `##### ${text.trim()}`; }, 'h6'(el, text) { return `###### ${text.trim()}`; }, 'strong'(el, text) { return `**${text}**`; }, 'em'(el, text) { return `_${text}_`; }, 'del'(el, text) { return `~~${text}~~`; }, 'sup'(el, text) { return `^${text}`; }, 'hr'(el) { return '-----'; }, 'table'(el) { const theadEl = el.querySelector('thead'); const tbodyEl = el.querySelector('tbody'); if (!theadEl || !tbodyEl) return false; const theadText = CopyAsGFM.nodeToGFM(theadEl); const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); return [theadText, tbodyText].join('\n'); }, 'thead'(el, text) { const cells = _.map(el.querySelectorAll('th'), (cell) => { let chars = CopyAsGFM.nodeToGFM(cell).length + 2; let before = ''; let after = ''; switch (cell.style.textAlign) { case 'center': before = ':'; after = ':'; chars -= 2; break; case 'right': after = ':'; chars -= 1; break; default: break; } chars = Math.max(chars, 3); const middle = Array(chars + 1).join('-'); return before + middle + after; }); const separatorRow = `|${cells.join('|')}|`; return [text, separatorRow].join('\n'); }, 'tr'(el) { const cellEls = el.querySelectorAll('td, th'); if (cellEls.length === 0) return false; const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); return `| ${cells.join(' | ')} |`; }, }, }; export class CopyAsGFM { constructor() { // iOS currently does not support clipboardData.setData(). This bug should // be fixed in iOS 12, but for now we'll disable this for all iOS browsers // ref: https://trac.webkit.org/changeset/222228/webkit const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent); if (isIOS) return; $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); } static copyAsGFM(e, transformer) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; const documentFragment = getSelectedFragment(); if (!documentFragment) return; const el = transformer(documentFragment.cloneNode(true), e.currentTarget); if (!el) return; e.preventDefault(); e.stopPropagation(); clipboardData.setData('text/plain', el.textContent); clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); } static pasteGFM(e) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; const text = clipboardData.getData('text/plain'); const gfm = clipboardData.getData('text/x-gfm'); if (!gfm) return; e.preventDefault(); window.gl.utils.insertText(e.target, (textBefore, textAfter) => { // If the text before the cursor contains an odd number of backticks, // we are either inside an inline code span that starts with 1 backtick // or a code block that starts with 3 backticks. // This logic still holds when there are one or more _closed_ code spans // or blocks that will have 2 or 6 backticks. // This will break down when the actual code block contains an uneven // number of backticks, but this is a rare edge case. const backtickMatch = textBefore.match(/`/g); const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1; if (insideCodeBlock) { return text; } return gfm; }); } static transformGFMSelection(documentFragment) { const gfmElements = documentFragment.querySelectorAll('.md, .wiki'); switch (gfmElements.length) { case 0: { return documentFragment; } case 1: { return gfmElements[0]; } default: { const allGfmElement = document.createElement('div'); for (let i = 0; i < gfmElements.length; i += 1) { const gfmElement = gfmElements[i]; allGfmElement.appendChild(gfmElement); allGfmElement.appendChild(document.createTextNode('\n\n')); } return allGfmElement; } } } static transformCodeSelection(documentFragment, target) { let lineSelector = '.line'; if (target) { const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0]; if (lineClass) { lineSelector = `.line_content.${lineClass} ${lineSelector}`; } } const lineElements = documentFragment.querySelectorAll(lineSelector); let codeElement; if (lineElements.length > 1) { codeElement = document.createElement('pre'); codeElement.className = 'code highlight'; const lang = lineElements[0].getAttribute('lang'); if (lang) { codeElement.setAttribute('lang', lang); } } else { codeElement = document.createElement('code'); } if (lineElements.length > 0) { for (let i = 0; i < lineElements.length; i += 1) { const lineElement = lineElements[i]; codeElement.appendChild(lineElement); codeElement.appendChild(document.createTextNode('\n')); } } else { codeElement.appendChild(documentFragment); } return codeElement; } static nodeToGFM(node, respectWhitespaceParam = false) { if (node.nodeType === Node.COMMENT_NODE) { return ''; } if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); const text = this.innerGFM(node, respectWhitespace); if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { return text; } for (const filter in gfmRules) { const rules = gfmRules[filter]; for (const selector in rules) { const func = rules[selector]; if (!nodeMatchesSelector(node, selector)) continue; let result; if (func.length === 2) { // if `func` takes 2 arguments, it depends on text. // if there is no text, we don't need to generate GFM for this node. if (text.length === 0) continue; result = func(node, text); } else { result = func(node); } if (result === false) continue; return result; } } return text; } static innerGFM(parentNode, respectWhitespace = false) { const nodes = parentNode.childNodes; const clonedParentNode = parentNode.cloneNode(true); const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); for (let i = 0; i < nodes.length; i += 1) { const node = nodes[i]; const clonedNode = clonedNodes[i]; const text = this.nodeToGFM(node, respectWhitespace); // `clonedNode.replaceWith(text)` is not yet widely supported clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); } let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; if (!respectWhitespace) { nodeText = nodeText.trim(); } return nodeText; } } // Export CopyAsGFM as a global for rspec to access // see /spec/features/copy_as_gfm_spec.rb if (process.env.NODE_ENV !== 'production') { window.CopyAsGFM = CopyAsGFM; } export default function initCopyAsGFM() { return new CopyAsGFM(); }